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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

![Cafe Code desktop screenshot](./docs/images/cafe-code-desktop.png)

Made in Japan with love.

_Cafe Code is very small, barely does a thing at all. Chat goes in and chat comes out, soft and sweet, without a shout._

Cafe Code is a tiny desktop GUI for coding agents. It is a fork of [T3 Code](https://github.com/pingdotgg/t3code), with a basket of bug fixes, a little sweep-up, and some very opinionated trimming for people who want the agent chat and not much else.
Expand Down Expand Up @@ -36,6 +38,7 @@ dashboard, a project-management suite, or a museum of buttons, no.

This is the practical working list. It will probably get cleaned up later.

- Numerous bug fixes.
- Rebranded the app around Cafe Code.
- Moved local app data into `~/.cafe-code`.
- Removed the in-app terminal drawer and terminal UI.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ await waitForResources({

const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;
childEnv.CAFE_CODE_DESKTOP_DEV = "true";

let shuttingDown = false;
let restartTimer = null;
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";

const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;
delete childEnv.CAFE_CODE_DESKTOP_DEV;
delete childEnv.VITE_DEV_SERVER_URL;
delete childEnv.CAFE_CODE_DEV_URL;

const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs", ...process.argv.slice(2)], {
stdio: "inherit",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const DesktopConfig = Config.all({
appDataDirectory: trimmedString("APPDATA"),
xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
cafeCodeHome: trimmedString("CAFE_CODE_HOME"),
desktopDevelopmentMode: optionalBoolean("CAFE_CODE_DESKTOP_DEV"),
devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
devRemoteServerEntryPath: trimmedString("CAFE_CODE_DEV_REMOTE_SERVER_ENTRY_PATH"),
configuredBackendPort: cafeCodeOptionalConfig("CAFE_CODE_PORT", Config.port),
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("DesktopEnvironment", () => {
{
CAFE_CODE_HOME: " /tmp/t3 ",
CAFE_CODE_COMMIT_HASH: " 0123456789abcdef ",
CAFE_CODE_DESKTOP_DEV: "true",
CAFE_CODE_PORT: "4949",
VITE_DEV_SERVER_URL: "http://localhost:5173",
CAFE_CODE_DEV_REMOTE_SERVER_ENTRY_PATH: " /remote/server.mjs ",
Expand Down Expand Up @@ -83,6 +84,25 @@ describe("DesktopEnvironment", () => {
}),
);

it.effect("does not switch app identity to development from an inherited Vite URL", () =>
Effect.gen(function* () {
const environment = yield* makeEnvironment(
{},
{
CAFE_CODE_HOME: "/tmp/t3",
VITE_DEV_SERVER_URL: "http://localhost:5173",
},
);

assert.equal(environment.isDevelopment, false);
assert.equal(environment.stateDir, "/tmp/t3/userdata");
assert.equal(environment.branding.stageLabel, "Alpha");
assert.equal(environment.displayName, "Cafe Code (Alpha)");
assert.equal(environment.userDataDirName, "cafecode");
assert.equal(environment.appUserModelId, "com.cafeai.cafecode");
}),
);

it.effect("derives production state paths under userdata", () =>
Effect.gen(function* () {
const environment = yield* makeEnvironment(
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
const config = yield* DesktopConfig.DesktopConfig;
const homeDirectory = input.homeDirectory;
const devServerUrl = config.devServerUrl;
const isDevelopment = Option.isSome(devServerUrl);
const isDevelopment = config.desktopDevelopmentMode;
const appDataDirectory =
input.platform === "win32"
? Option.getOrElse(config.appDataDirectory, () =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const makeEnvironmentLayer = (baseDir: string) =>
NodeServices.layer,
DesktopConfig.layerTest({
CAFE_CODE_HOME: baseDir,
CAFE_CODE_DESKTOP_DEV: "true",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
}),
),
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ describe("DesktopBackendConfiguration", () => {
assert.isUndefined(first.env.CAFE_CODE_PORT);
assert.isUndefined(first.env.CAFE_CODE_MODE);
assert.isUndefined(first.env.CAFE_CODE_DESKTOP_LAN_HOST);
assert.isUndefined(first.env.CAFE_CODE_DESKTOP_DEV);
assert.isUndefined(first.env.CAFE_CODE_DEV_URL);
assert.isUndefined(first.env.VITE_DEV_SERVER_URL);

assert.equal(first.bootstrap.mode, "desktop");
assert.equal(first.bootstrap.noBrowser, true);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [
"CAFE_CODE_MODE",
"CAFE_CODE_NO_BROWSER",
"CAFE_CODE_HOST",
"CAFE_CODE_DEV_URL",
"CAFE_CODE_DESKTOP_DEV",
"CAFE_CODE_DESKTOP_WS_URL",
"CAFE_CODE_DESKTOP_LAN_ACCESS",
"CAFE_CODE_DESKTOP_LAN_HOST",
"CAFE_CODE_DESKTOP_HTTPS_ENDPOINTS",
"CAFE_CODE_TAILSCALE_SERVE",
"CAFE_CODE_TAILSCALE_SERVE_PORT",
"VITE_DEV_SERVER_URL",
] as const;

const backendChildEnvPatch = (): Record<string, string | undefined> =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
CAFE_CODE_DESKTOP_DEV: "true",
CAFE_CODE_PORT: "3773",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
}),
Expand Down
40 changes: 40 additions & 0 deletions apps/server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Cafe Code

A minimal AI chat harness for coding agents.

Cafe Code is a tiny local UI for Codex and Claude. It is a fork of
[T3 Code](https://github.com/pingdotgg/t3code), trimmed down around one idea:
type a prompt, let the agent work, and keep the interface quiet, fast, and out
of your way.

No terminal drawer. No pretend IDE. No release dashboard. If you want a console,
use a real console. If you want to inspect code, open it in VS Code.

## Run

```bash
npx @cafeai/cafe-code
```

`npx` downloads the package if needed and starts Cafe Code immediately.

If you want a normal command on your machine:

```bash
npm install -g @cafeai/cafe-code
cafe-code
```

Cafe Code expects providers to already be installed and authenticated:

- Codex: install Codex CLI and run `codex login`
- Claude: install Claude Code and run `claude auth login`

## Notes

The npm package runs the local Cafe Code server and bundled UI. Desktop
installers are not published yet.

## License

AGPL-3.0-or-later.
20 changes: 19 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
{
"name": "@cafeai/cafe-code",
"version": "0.0.25",
"description": "A minimal AI chat harness for coding agents.",
"keywords": [
"agent",
"ai",
"claude",
"codex",
"coding-agent",
"desktop",
"electron"
],
"homepage": "https://github.com/cafeai/cafe-code#readme",
"bugs": {
"url": "https://github.com/cafeai/cafe-code/issues"
},
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand All @@ -11,9 +25,13 @@
"cafe-code": "./dist/bin.mjs"
},
"files": [
"dist"
"dist",
"README.md"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "node --watch src/bin.ts",
"build": "node scripts/cli.ts build",
Expand Down
16 changes: 16 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@ import serverPackageJson from "../package.json" with { type: "json" };

interface PackageJson {
name: string;
description?: string;
license: string;
homepage?: string;
bugs?: {
url: string;
};
repository: {
type: string;
url: string;
directory: string;
};
keywords?: string[];
bin: Record<string, string>;
type: string;
version: string;
engines: Record<string, string>;
files: string[];
publishConfig?: {
access: string;
};
dependencies: Record<string, string>;
overrides: Record<string, string>;
}
Expand Down Expand Up @@ -217,12 +227,18 @@ const publishCmd = Command.make(
const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);
const pkg: PackageJson = {
name: serverPackageJson.name,
description: serverPackageJson.description,
license: serverPackageJson.license,
homepage: serverPackageJson.homepage,
bugs: serverPackageJson.bugs,
repository: serverPackageJson.repository,
keywords: serverPackageJson.keywords,
bin: serverPackageJson.bin,
type: serverPackageJson.type,
version,
engines: serverPackageJson.engines,
files: serverPackageJson.files,
publishConfig: serverPackageJson.publishConfig,
dependencies: resolveCatalogDependencies(
serverPackageJson.dependencies,
rootPackageJson.workspaces.catalog,
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
CAFE_CODE_PORT: "4001",
CAFE_CODE_HOST: "0.0.0.0",
CAFE_CODE_HOME: baseDir,
CAFE_CODE_DEV_URL: "http://127.0.0.1:5173",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
CAFE_CODE_NO_BROWSER: "true",
CAFE_CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
Expand Down Expand Up @@ -132,6 +133,48 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
}),
);

it.effect("ignores renderer-only Vite dev URL unless Cafe Code dev URL is explicit", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-vite-only-" });
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
const resolved = yield* resolveServerConfig(
{
mode: Option.some("desktop"),
port: Option.none(),
host: Option.none(),
baseDir: Option.none(),
cwd: Option.none(),
devUrl: Option.none(),
noBrowser: Option.none(),
bootstrapFd: Option.none(),
autoBootstrapProjectFromCwd: Option.none(),
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
},
Option.none(),
).pipe(
Effect.provide(
Layer.mergeAll(
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
CAFE_CODE_HOME: baseDir,
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
},
}),
),
NetService.layer,
),
),
);

expect(resolved.devUrl).toBeUndefined();
expect(resolved.stateDir).toBe(derivedPaths.stateDir);
}),
);

it.effect("uses CLI flags when provided", () =>
Effect.gen(function* () {
const { join } = yield* Path.Path;
Expand Down Expand Up @@ -164,6 +207,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
CAFE_CODE_PORT: "4001",
CAFE_CODE_HOST: "0.0.0.0",
CAFE_CODE_HOME: join(NodeOS.tmpdir(), "ignored-base"),
CAFE_CODE_DEV_URL: "http://127.0.0.1:5173",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
CAFE_CODE_NO_BROWSER: "false",
CAFE_CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const baseDirFlag = Flag.string("base-dir").pipe(
);
export const devUrlFlag = Flag.string("dev-url").pipe(
Flag.withSchema(Schema.URLFromString),
Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
Flag.withDescription("Development web URL to proxy/redirect to."),
Flag.optional,
);
export const noBrowserFlag = Flag.boolean("no-browser").pipe(
Expand Down Expand Up @@ -114,7 +114,7 @@ const EnvServerConfig = Config.all({
port: cafeCodeOptionalValueConfig("CAFE_CODE_PORT", Config.port),
host: cafeCodeOptionalValueConfig("CAFE_CODE_HOST", Config.string),
cafeCodeHome: cafeCodeOptionalValueConfig("CAFE_CODE_HOME", Config.string),
devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
devUrl: Config.url("CAFE_CODE_DEV_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
noBrowser: cafeCodeOptionalValueConfig("CAFE_CODE_NO_BROWSER", Config.boolean),
bootstrapFd: cafeCodeOptionalValueConfig("CAFE_CODE_BOOTSTRAP_FD", Config.int),
autoBootstrapProjectFromCwd: cafeCodeOptionalValueConfig(
Expand Down
37 changes: 4 additions & 33 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,14 +1128,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
if (event.payload.turnId === null || event.payload.role !== "assistant") {
return;
}
const activeSession = yield* projectionThreadSessionRepository.getByThreadId({
threadId: event.payload.threadId,
});
const sessionIsStillRunningThisTurn =
Option.isSome(activeSession) &&
activeSession.value.status === "running" &&
activeSession.value.activeTurnId === event.payload.turnId;
const shouldHoldTurnOpen = event.payload.streaming || sessionIsStillRunningThisTurn;
const shouldHoldTurnOpen = event.payload.streaming;
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
Expand Down Expand Up @@ -1223,27 +1216,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
threadId: event.payload.threadId,
turnId: event.payload.turnId,
});
const activeSession = yield* projectionThreadSessionRepository.getByThreadId({
threadId: event.payload.threadId,
});
const activeSessionStillOwnsTurn =
Option.isSome(activeSession) &&
activeSession.value.activeTurnId === event.payload.turnId &&
activeSession.value.status !== "error" &&
activeSession.value.status !== "interrupted" &&
activeSession.value.status !== "stopped";
const existingTurnStillRunning =
Option.isSome(existingTurn) &&
existingTurn.value.state === "running" &&
existingTurn.value.completedAt === null;
const shouldHoldTurnOpen =
event.payload.status !== "error" &&
(activeSessionStillOwnsTurn || existingTurnStillRunning);
const nextState = shouldHoldTurnOpen
? "running"
: event.payload.status === "error"
? "error"
: "completed";
const nextState = event.payload.status === "error" ? "error" : "completed";
yield* projectionTurnRepository.clearCheckpointTurnConflict({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
Expand All @@ -1261,9 +1234,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
checkpointFiles: event.payload.files,
startedAt: existingTurn.value.startedAt ?? event.payload.completedAt,
requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt,
completedAt: shouldHoldTurnOpen
? (existingTurn.value.completedAt ?? null)
: event.payload.completedAt,
completedAt: event.payload.completedAt,
});
return;
}
Expand All @@ -1277,7 +1248,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
state: nextState,
requestedAt: event.payload.completedAt,
startedAt: event.payload.completedAt,
completedAt: shouldHoldTurnOpen ? null : event.payload.completedAt,
completedAt: event.payload.completedAt,
checkpointTurnCount: event.payload.checkpointTurnCount,
checkpointRef: event.payload.checkpointRef,
checkpointStatus: event.payload.status,
Expand Down
Loading
Loading