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
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# AGENTS.md

Guidance for any AI agent or contributor working on MoCode.

## UI standard — read before touching the CLI

**[`DESIGN.md`](./DESIGN.md) is the single source of truth for all terminal UI.**

MoCode is a terminal-native app (OpenTUI + React), not a web app. Before adding
or changing anything under `packages/cli/src/components/`, `screens/`, or
`layouts/`, conform to DESIGN.md:

- Layout is **bottom-anchored**: scrolling transcript above, pinned composer +
single status line below.
- Measurements are **character cells**, not pixels. No fonts, `px`,
border-radius, or shadows.
- Color is **theme-driven**: resolve semantic tokens via `useTheme()`
(`packages/cli/src/theme.ts`). **Never hardcode hex** in a component.
- Mode tint is global: `{color.primary}` (Build) / `{color.planMode}` (Plan) on
the accent bar (`┃`), `◉`, and spinner.
- Reference DESIGN.md tokens (`{color.*}`, `{glyph.*}`, `{space.*}`, `{size.*}`).
If you need a value that isn't a token, add it to DESIGN.md first, then use it.
- Follow the dialog shell + Width & Overflow rules (cap then truncate, no phantom
scrollbars, single `esc` affordance in the header).

If a plan or `/gsd-ui-phase` UI-SPEC contradicts DESIGN.md, DESIGN.md wins —
reconcile to it.
449 changes: 449 additions & 0 deletions DESIGN.md

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/agent-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ In Build mode the model should:
## Plan mode

Plan mode has no bash tool. Git inspection uses read-only `gitStatus` and `gitDiff` tools instead.

8 changes: 8 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@
"typescript": "^5.4"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.85",
"@ai-sdk/cerebras": "^2.0.51",
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/groq": "^3.0.21",
"@ai-sdk/openai": "^3.0.72",
"@ai-sdk/react": "^3.0.210",
"@mocode/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@openrouter/ai-sdk-provider": "^2.9.1",
"@opentui/core": "^0.4.1",
"@opentui/react": "^0.4.1",
"@vscode/ripgrep": "^1.18.0",
"ai": "^6.0.208",
"chokidar": "^5.0.0",
"date-fns": "^4.4.0",
"hono": "^4.12.25",
"open": "^11.0.0",
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/components/command-menu/commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
ThemeDialogContent,
AgentsDialogContent,
SessionDialogContent,
ModelsDialogContent
ModelsDialogContent,
KeysWizardDialogContent,
McpDialogContent,
} from "../dialogs";
import { SUPPORTED_CHAT_MODELS } from "@mocode/shared";

Expand Down Expand Up @@ -64,6 +66,28 @@ export const COMMANDS: Command[] = [
});
},
},
{
name: "mcp",
description: "Manage MCP servers",
value: "/mcp",
action: (ctx) => {
ctx.dialog.open({
title: "MCP Servers",
children: <McpDialogContent />,
});
},
},
{
name: "keys",
description: "Configure API keys",
value: "/keys",
action: (ctx) => {
ctx.dialog.open({
title: "API Keys",
children: <KeysWizardDialogContent />,
});
},
},
{
name: "theme",
description: "Change color theme",
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/components/dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ export { ThemeDialogContent } from "./theme-dialog";
export { SessionDialogContent } from "./sessions-dialog";
export { AgentsDialogContent } from "./agents-dialog";
export { ModelsDialogContent } from "./models-dialog";
export { BashApprovalDialog } from "./bash-approval-dialog";
export { KeysWizardDialogContent } from "./keys-wizard-dialog";
export { McpDialogContent } from "./mcp-dialog";
export { BashApprovalDialog } from "./bash-approval-dialog";
export { McpApprovalDialog } from "./mcp-approval-dialog";
227 changes: 227 additions & 0 deletions packages/cli/src/components/dialogs/keys-wizard-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* `/keys` BYOK API key wizard (Phase 02, D-12).
*
* Two views: provider list (masked key preview) → edit form (paste key, save to keys.json).
* Keys never leave the machine; chmod 600 on `~/.mocode/keys.json`.
*/
import { TextAttributes, type InputRenderable } from "@opentui/core";
import { useKeyboard } from "@opentui/react";
import { useCallback, useRef, useState } from "react";
import { moveDialogSelection } from "../../lib/dialog-action-nav";
import { getKeys, saveKeys, type ProviderKeys } from "../../lib/keys";
import { useKeyboardLayer } from "../../providers/keyboard-layer";
import { useTheme } from "../../providers/theme";
import { useToast } from "../../providers/toast";
import { DialogSearchList } from "../dialog-search-list";

const PROVIDERS = [
"anthropic",
"openai",
"google",
"groq",
"cerebras",
"openrouter",
] as const;

type ProviderId = (typeof PROVIDERS)[number];

type WizardView = "list" | "edit";

const EDIT_ACTION_COUNT = 2;

function maskApiKey(apiKey: string): string {
if (apiKey.length === 0) {
return "not set";
}
if (apiKey.length <= 4) {
return "••••";
}
return `${apiKey.slice(0, 4)}${"•".repeat(Math.min(8, apiKey.length - 4))}`;
}

function getProviderLabel(provider: ProviderId): string {
return provider.charAt(0).toUpperCase() + provider.slice(1);
}

type ActionButtonProps = {
label: string;
hint?: string;
selected?: boolean;
onSelect: () => void;
onMouseMove?: () => void;
};

function ActionButton({ label, hint, selected, onSelect, onMouseMove }: ActionButtonProps) {
const { colors } = useTheme();

return (
<box
flexDirection="row"
paddingX={1}
height={1}
backgroundColor={selected ? colors.selection : undefined}
onMouseMove={onMouseMove}
onMouseDown={onSelect}
>
<text selectable={false} fg={selected ? "black" : "white"} attributes={TextAttributes.BOLD}>
{label}
</text>
{hint ? (
<text selectable={false} fg={selected ? "black" : "gray"}>
{" "}
{hint}
</text>
) : null}
</box>
);
}

/** Multi-provider API key wizard opened by `/keys` (D-12). */
export function KeysWizardDialogContent() {
const { isTopLayer } = useKeyboardLayer();
const { show } = useToast();
const [view, setView] = useState<WizardView>("list");
const [keys, setKeys] = useState<ProviderKeys>(() => getKeys() ?? {});
const [editingProvider, setEditingProvider] = useState<ProviderId | null>(null);
const [draftKey, setDraftKey] = useState("");
const [actionIndex, setActionIndex] = useState(0);
const inputRef = useRef<InputRenderable>(null);

const handleSelectProvider = useCallback(
(provider: ProviderId) => {
setEditingProvider(provider);
setDraftKey(keys[provider]?.apiKey ?? "");
setActionIndex(0);
setView("edit");
},
[keys],
);

const handleBack = useCallback(() => {
setView("list");
setEditingProvider(null);
setDraftKey("");
}, []);

const handleSaveKey = useCallback(() => {
if (!editingProvider) {
return;
}

const nextKeys = { ...keys };
const trimmed = draftKey.trim();

if (trimmed.length === 0) {
delete nextKeys[editingProvider];
} else {
nextKeys[editingProvider] = { apiKey: trimmed };
}

try {
saveKeys(nextKeys);
setKeys(nextKeys);
handleBack();
} catch (error) {
show({
variant: "error",
message: error instanceof Error ? error.message : "Failed to save API key",
});
}
}, [keys, editingProvider, draftKey, handleBack, show]);

const handleContentChange = useCallback(() => {
setDraftKey(inputRef.current?.value ?? "");
}, []);

useKeyboard((key) => {
if (!isTopLayer("dialog") || view !== "edit") {
return;
}

if (key.name === "escape") {
handleBack();
} else if (key.name === "return" || key.name === "enter") {
key.preventDefault();
if (actionIndex === 0) {
handleSaveKey();
} else {
handleBack();
}
} else if (key.name === "up") {
key.preventDefault();
setActionIndex((index) => moveDialogSelection(index, "up", EDIT_ACTION_COUNT));
} else if (key.name === "down") {
key.preventDefault();
setActionIndex((index) => moveDialogSelection(index, "down", EDIT_ACTION_COUNT));
}
});

if (view === "list") {
return (
<box flexDirection="column" gap={1}>
<text attributes={TextAttributes.DIM}>
Select a provider to configure. Saved keys show a masked preview only.
</text>
<DialogSearchList
items={[...PROVIDERS]}
onSelect={handleSelectProvider}
filterFn={(provider, query) =>
getProviderLabel(provider).toLowerCase().includes(query.toLowerCase())
}
renderItem={(provider, isSelected) => (
<box flexDirection="row" gap={1} paddingX={1}>
<text selectable={false} fg={isSelected ? "black" : "white"}>
{getProviderLabel(provider)}
</text>
<text
selectable={false}
fg={isSelected ? "black" : "gray"}
attributes={TextAttributes.DIM}
>
{maskApiKey(keys[provider]?.apiKey ?? "")}
</text>
</box>
)}
getKey={(provider) => provider}
placeholder="Search providers..."
emptyText="No providers found"
/>
<text attributes={TextAttributes.DIM}>Esc to close</text>
</box>
);
}

const providerLabel = editingProvider ? getProviderLabel(editingProvider) : "Provider";
const actions = [
{ label: "Save key", hint: "(Enter)", onSelect: handleSaveKey },
{ label: "Back", hint: "(Esc)", onSelect: handleBack },
] as const;

return (
<box flexDirection="column" gap={1}>
<text attributes={TextAttributes.DIM}>API key for {providerLabel}</text>
<input
ref={inputRef}
placeholder="Paste API key..."
focused
value={draftKey}
onContentChange={handleContentChange}
/>
<text attributes={TextAttributes.DIM}>
Preview: {draftKey.length > 0 ? "•".repeat(Math.min(draftKey.length, 12)) : "(empty)"}
</text>
<box flexDirection="column" gap={0}>
{actions.map((action, index) => (
<ActionButton
key={action.label}
label={action.label}
hint={action.hint}
selected={index === actionIndex}
onSelect={action.onSelect}
onMouseMove={() => setActionIndex(index)}
/>
))}
</box>
</box>
);
}
Loading