Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
env:
# prisma generate (server postinstall) only needs the var set, not a live DB.
DATABASE_URL: postgresql://ci:ci@localhost:5432/ci
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"

- name: Install dependencies
run: bun install --frozen-lockfile
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Typecheck
run: bun run typecheck

- name: Lint
run: bun run lint

# Hard gate — merge is blocked if any test fails.
- name: Test
run: bun run test
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,39 @@ Follow [Karpathy behavioral guidelines](.cursor/rules/karpathy-behavioral-guidel
think before coding, minimum diff, verifiable success criteria. Every changed line
must trace directly to the request.

## Build & verify — run before claiming done

Bun monorepo. From the repo root:

- `bun run check` — runs typecheck + lint + test in one shot. **Run this after every change.**
- `bun run test` — `bun test` across `shared`, `server`, `cli`. **Must be 0 failures.**
- `bun run typecheck` — `tsc --noEmit` per package (production sources; `**/*.test.ts` excluded). **Must be 0 errors.**
- `bun run lint` — Biome linter (config: `biome.json`; formatter is intentionally **off**). **Must be 0 errors.**

The blocking gates are `bun run typecheck`, `bun run lint`, and `bun run test`. Do **not** mass-fix or
reformat unrelated code as a drive-by; fix only what your change touches.

## Package boundaries — do not violate

Dependency direction is one-way. Never add an import that points the wrong way.

- `@mocode/shared` — lowest layer. Tool contracts, Zod schemas, model catalog live **here**.
Must **not** import from `cli`, `server`, or `database`.
- `@mocode/database` — Prisma client. Code under `packages/database/generated/` is
**auto-generated — never hand-edit**. Change `schema.prisma`, then
`bun run --cwd packages/database db:generate`.
- `@mocode/server` — depends on `shared` + `database`.
- `@mocode/cli` — depends on `shared` (uses `server` only for types). **Nothing may import from `cli`.**

## Definition of Done

A change is done only when **all** hold:

1. `bun run test` passes (0 failures).
2. `bun run lint` passes (0 errors).
3. `bun run typecheck` passes (0 errors).
4. Every changed line traces directly to the request (no drive-by edits, no reformatting).

## UI standard — read before touching the CLI

**[`DESIGN.md`](./DESIGN.md) is the single source of truth for all terminal UI.**
Expand Down
28 changes: 28 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
"packages/**/src/**/*.ts",
"packages/**/src/**/*.tsx",
"!**/dist",
"!**/node_modules",
"!packages/database/generated"
]
},
"formatter": { "enabled": false },
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"a11y": {
"noStaticElementInteractions": "off"
},
"style": {
"noNonNullAssertion": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off"
}
}
}
}
22 changes: 22 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
"dev:cli": "bun run --watch packages/cli/src/index.tsx",
"dev:server": "bun run --hot packages/server/src/index.ts",
"build:cli": "bun run --filter @mocode/cli build",
"link:cli": "bun run build:cli && cd packages/cli && bun link"
"link:cli": "bun run build:cli && cd packages/cli && bun link",
"typecheck": "bunx tsc -p packages/shared/tsconfig.json --noEmit && bunx tsc -p packages/server/tsconfig.json --noEmit && bunx tsc -p packages/cli/tsconfig.json --noEmit && bunx tsc -p packages/database/tsconfig.json --noEmit",
"lint": "biome lint",
"test": "bun test packages/shared packages/server packages/cli",
"check": "bun run typecheck && bun run lint && bun run test"
},
"dependencies": {
"dotenv": "^17.4.2"
},
"devDependencies": {
"@biomejs/biome": "2.5.1"
}
}
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/groq": "^3.0.21",
"@ai-sdk/openai": "^3.0.72",
"@ai-sdk/provider-utils": "^4.0.30",
"@ai-sdk/react": "^3.0.210",
"@mocode/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function useCommandMenu():UseCommandMenuReturn {
const [selectedIndex,setSelectedIndex] = useState(0);
const [showCommandMenu,setShowCommandMenu] = useState(false);

const { push, pop, isTopLayer, setResponder } = useKeyboardLayer();
const { push, pop, isTopLayer } = useKeyboardLayer();

// Closing the menu also releases the "command" keyboard layer.
const close = ()=>{
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/components/dialogs/mcp-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function McpDialogContent() {

const interval = setInterval(() => bump(), 1000);
return () => clearInterval(interval);
}, [servers, busyServers, bump]);
}, [servers, bump]);

const handleReconnect = useCallback(
async (server: McpServerStatus) => {
Expand Down
25 changes: 13 additions & 12 deletions packages/cli/src/components/input-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readdir } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import { useRef, useState, useCallback, useEffect, type RefObject } from "react";
import { useRef, useState, useCallback, useEffect, useMemo, type RefObject } from "react";
import { TextAttributes } from "@opentui/core";
import type { TextareaRenderable, ScrollBoxRenderable } from "@opentui/core";
import { useKeyboard, useRenderer } from "@opentui/react";
Expand Down Expand Up @@ -381,13 +381,6 @@ export function InputBar({
syncMentionMenu(nextText, textarea.cursorOffset);
}, [mentionCandidates, syncMentionMenu]);

const handleTextareaCursorChange = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;

syncMentionMenu(textarea.plainText, textarea.cursorOffset);
}, [syncMentionMenu]);

const handleCommand = useCallback((
command: Command | undefined
) => {
Expand All @@ -412,7 +405,7 @@ export function InputBar({
(message) => toast.show({ variant: "error", message }),
);
} else {
textarea.insertText(command.value + " ");
textarea.insertText(`${command.value} `);
}
}, [renderer, toast, dialog, navigate, mode, model, setMode, setModel]);

Expand Down Expand Up @@ -507,12 +500,20 @@ export function InputBar({
return () => setResponder("base", null);
}, [disabled, setResponder]);

const composerRestore = useMemo(
() =>
composerRestoreText != null
? { text: composerRestoreText, token: composerRestoreToken }
: null,
[composerRestoreText, composerRestoreToken],
);

useEffect(() => {
if (!composerRestoreText) return;
if (!composerRestore) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.setText(composerRestoreText);
}, [composerRestoreText, composerRestoreToken]);
textarea.setText(composerRestore.text);
}, [composerRestore]);

useKeyboard((key) => {
if (disabled) return;
Expand Down
33 changes: 4 additions & 29 deletions packages/cli/src/components/messages/bot-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
shouldShowDurationInFooter,
shouldShowGeneratingInFooter,
} from "../../lib/bot-message-footer";
import { groupConsecutiveParts, partRenderKey } from "../../lib/bot-message-parts";
import type { LanguageModelUsage } from "ai";
import prettyMs from "pretty-ms";
import { EmptyBorder } from "../border";
Expand Down Expand Up @@ -86,32 +87,6 @@ function formatBashToolDisplay(input: unknown): BashToolDisplay | null {
return { command: record.command, description };
}

type PartGroup = {
type: ClientMessagePart["type"];
parts: ClientMessagePart[];
key: string;
};

/** Merge adjacent parts of the same type so reasoning/tool blocks stack cleanly. */
function groupConsecutiveParts(parts: ClientMessagePart[]): PartGroup[] {
const groups: PartGroup[] = [];

for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
const lastGroup = groups[groups.length - 1];

if (lastGroup && lastGroup.type === part.type) {
lastGroup.parts.push(part);
} else {
const key =
isToolPart(part) ? `group-tc-${part.toolCallId}` : `group-${part.type}-${i}`;
groups.push({ type: part.type, parts: [part], key });
}
}

return groups;
};

export function BotMessage({
parts,
model,
Expand Down Expand Up @@ -139,11 +114,11 @@ export function BotMessage({
<box width="100%" alignItems="center">
{groupConsecutiveParts(parts).map((group, i) => (
<box key={group.key} width="100%" paddingTop={i === 0 ? 0 : 1}>
{group.parts.map((part, j) => {
{group.parts.map((part, partIndex) => {
if (part.type === "reasoning") {
return (
<box
key={`reasoning-${j}`}
key={partRenderKey(group.key, "reasoning", partIndex)}
border={["left"]}
borderColor={colors.thinkingBorder}
customBorderChars={{
Expand Down Expand Up @@ -203,7 +178,7 @@ export function BotMessage({

if (part.type === "text") {
return (
<box key={`text-${j}`} paddingX={3} width="100%">
<box key={partRenderKey(group.key, "text", partIndex)} paddingX={3} width="100%">
<text>{part.text}</text>
</box>
);
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useMemo, useCallback, useRef, useEffect, useState } from "react";
import { useChat as useAiChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
type ChatTransport,
type InferUITools,
lastAssistantMessageIsCompleteWithToolCalls,
type LanguageModelUsage,
Expand Down Expand Up @@ -176,11 +177,11 @@ export function useChat(
const transport = useMemo(() => {
// BYOK: inference + tool schema merge run in-process; MCP execution stays in onToolCall below.
if (isLocalMode()) {
return new LocalChatTransport<Message>({
return new LocalChatTransport({
resolveModel: resolveChatModel,
getMcpManager,
buildSystemPrompt,
});
}) as ChatTransport<Message>;
}

const chatFetch = (async (input, init) => {
Expand Down Expand Up @@ -403,7 +404,7 @@ export function useChat(
if (pending.length === 0) return;

chat.setMessages((msgs) => finalizeInterruptedAssistant(msgs));
}, [turnInterrupted, chat.status, chat.messages, chat.setMessages]);
}, [turnInterrupted, chat.messages, chat.setMessages]);

const interrupt = useCallback(() => {
turnInterruptedRef.current = true;
Expand Down Expand Up @@ -464,7 +465,7 @@ export function useChat(
await chat.regenerate({ messageId: lastUser.id });
}
},
[chat.messages, chat.regenerate, chat.setMessages, chat.status, chat.clearError],
[chat],
);

const resumeStream = useCallback(async () => {
Expand All @@ -476,7 +477,7 @@ export function useChat(
if (pruned.length === chat.messages.length) return;

chat.setMessages(pruned);
}, [chat.error, chat.status, chat.messages, chat.setMessages]);
}, [chat.messages, chat.setMessages]);

const submit = useCallback(
(params: { userText: string; mode: ModeType; model: SupportedChatModelId }) => {
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/lib/bot-message-parts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { groupConsecutiveParts, partRenderKey } from "./bot-message-parts";

describe("groupConsecutiveParts", () => {
test("merges adjacent reasoning parts into one group", () => {
const groups = groupConsecutiveParts([
{ type: "reasoning", text: "a" },
{ type: "reasoning", text: "b" },
{ type: "text", text: "answer" },
] as never);

expect(groups).toHaveLength(2);
expect(groups[0]?.type).toBe("reasoning");
expect(groups[0]?.parts).toHaveLength(2);
expect(groups[1]?.type).toBe("text");
});
});

describe("partRenderKey", () => {
test("stays unique when duplicate text appears in the same group", () => {
const groupKey = "group-reasoning-0";
const keys = [0, 1].map((index) => partRenderKey(groupKey, "reasoning", index));

expect(new Set(keys).size).toBe(2);
expect(keys).toEqual(["group-reasoning-0-reasoning-0", "group-reasoning-0-reasoning-1"]);
});
});
Loading
Loading