Add terminal client, memory, model fallback, version and install improvements

- codecraft: full-screen terminal client (ratatui) – local tools and files,
  models via the CodeCraft server gateway or own providers (standalone with a
  guided setup), slash commands, @files, permission dialogs, resume, update
- Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the
  server: /cli/install.sh and /cli/download/<target>
- Project memory (remember tool, .codecraft/memory.md) and personal memory
- Model fallback when a host is unreachable or a budget is used up
- Version/commit in UI and health, daily release check, "What's new" dialog
- PWA: install button, maskable icon, manifest id and screenshots
- Case-insensitive detection of connection errors for retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:13:06 +01:00
1 parent aa1f8cf016
commit 6dffcb501a
52 files changed
+6477 -765

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+3 -1
View File
@@ -15,6 +15,7 @@ import { Jobs } from "./components/Jobs";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
import { WhatsNew } from "./components/WhatsNew";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -31,7 +32,7 @@ export function App() {
const refresh = async () => {
try {
const i = await get<Info>("/info");
setState(() => ({ me: i.user }));
setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) {
await loadSessions();
void startPrefsSync();
@@ -70,6 +71,7 @@ export function App() {
<>
<Login info={info} onSuccess={refresh} />
<DialogHost />
{info?.user && <WhatsNew version={info.version} />}
</>
);
+30
View File
@@ -5,6 +5,7 @@ import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
@@ -109,12 +110,36 @@ function InterfaceSettings() {
<div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label>
<PushControl />
<label>{t("install.label")}</label>
<InstallControl />
<label>{t("settings.version")}</label>
<VersionInfo />
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
function InstallControl() {
const t = useT();
const prompt = useSyncExternalStore(onInstallChange, canPrompt);
if (isInstalled()) return <span className="chip">{t("install.installed")}</span>;
if (prompt)
return (
<div>
<button className="small primary" onClick={() => void promptInstall()}>
<Icon name="download" /> {t("install.button")}
</button>
</div>
);
return <div className="hint">{t(`install.hint.${platformHint()}` as Key)}</div>;
}
function VersionInfo() {
const info = useFetch<{ version: string; commit?: string }>("/info").data;
return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>;
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
@@ -313,6 +338,7 @@ interface ModelRow {
cache_read_price?: number;
cache_write_price?: number;
max_concurrency?: number;
fallback?: string;
options?: Record<string, unknown>;
}
@@ -479,6 +505,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<th>{t("settings.priceCacheRead")}</th>
<th>{t("settings.priceCacheWrite")}</th>
<th title={t("settings.maxConcurrencyHint")}>{t("settings.slots")}</th>
<th title={t("settings.fallbackHint")}>{t("settings.fallback")}</th>
<th />
</tr>
</thead>
@@ -516,6 +543,9 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<td>
<input type="number" min={1} value={m.max_concurrency ?? ""} placeholder="∞" onChange={(e) => setModel(i, { max_concurrency: num(e.target.value) })} />
</td>
<td>
<ModelSelect value={m.fallback ?? ""} onChange={(v) => setModel(i, { fallback: v || undefined })} emptyLabel="–" />
</td>
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
+6
View File
@@ -28,6 +28,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
const update = useStore((s) => s.update);
// Full-text search over titles and messages.
const [query, setQuery] = useState("");
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
@@ -132,6 +133,11 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</div>
))}
</nav>
{update && (
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}>
<Icon name="sparkle" /> {t("update.available", { version: update.version })}
</a>
)}
<div className="sidebar-user">
<span title={me?.username}>
{me?.display_name || me?.username}
+8
View File
@@ -86,6 +86,14 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
);
}
if (call.name === "remember") {
return (
<div className="todo-line memory-card">
<Icon name="brain" /> {t("memory.remembered")}: {call.input?.note}
</div>
);
}
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
const writtenImage =
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
+46
View File
@@ -0,0 +1,46 @@
import { useState } from "react";
import { getLang, useT } from "../i18n";
import { WHATS_NEW } from "../whatsnew";
import { Icon } from "./Icon";
const KEY = "codecraft.seenVersion";
/** Shown once after the server was updated to a version with release notes. */
export function WhatsNew({ version }: { version: string }) {
const t = useT();
const seen = localStorage.getItem(KEY);
const [open, setOpen] = useState(() => {
// First visit ever: just remember the version.
if (!seen) {
localStorage.setItem(KEY, version);
return false;
}
return seen !== version && !!WHATS_NEW[version];
});
if (!open) return null;
const entry = WHATS_NEW[version];
const items = getLang() === "de" ? entry.de : entry.en;
const close = () => {
localStorage.setItem(KEY, version);
setOpen(false);
};
return (
<div className="modal-backdrop" onClick={close}>
<div className="modal" role="dialog" aria-label={t("whatsnew.title")} onClick={(e) => e.stopPropagation()}>
<h3>
<Icon name="sparkle" /> {t("whatsnew.title", { version })}
</h3>
<ul className="whatsnew">
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
<div className="row" style={{ justifyContent: "flex-end" }}>
<button className="primary" onClick={close}>
{t("common.ok")}
</button>
</div>
</div>
</div>
);
}
+4
View File
@@ -4,6 +4,8 @@ import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
export function Account() {
const t = useT();
@@ -46,7 +48,9 @@ export function Account() {
</button>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<Memory />
<GitAccount />
<CliInstall />
<ApiTokens />
</>
);
@@ -0,0 +1,44 @@
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
/** How to install and use the terminal client. */
export function CliInstall() {
const t = useT();
const cmd = `curl -fsSL ${location.origin}/cli/install.sh | sh`;
const copy = (s: string) => navigator.clipboard.writeText(s).then(() => toast(t("tokens.copied"), "success"));
return (
<div className="panel">
<h3>
<Icon name="terminal" /> {t("cli.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("cli.intro")}
</div>
<ol className="cli-steps">
<li>
{t("cli.step1")}
<div className="row" style={{ gap: 6, marginTop: 4 }}>
<code className="token-value">{cmd}</code>
<button className="small" onClick={() => copy(cmd)}>
<Icon name="copy" /> {t("tokens.copy")}
</button>
</div>
</li>
<li>
{t("cli.step2")}
<div className="row" style={{ gap: 6, marginTop: 4 }}>
<code className="token-value">{`codecraft login ${location.origin}`}</code>
<button className="small" onClick={() => copy(`codecraft login ${location.origin}`)}>
<Icon name="copy" /> {t("tokens.copy")}
</button>
</div>
</li>
<li>{t("cli.step3")}</li>
</ol>
<div className="hint">
{t("cli.downloads")} <a href="/cli/download/linux-x86_64">Linux x86_64</a> · <a href="/cli/download/linux-aarch64">Linux arm64</a>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
/** Personal memory: preferences that apply in every project (web and terminal). */
export function Memory() {
const t = useT();
const [text, setText] = useState<string>();
useEffect(() => {
get<{ memory: string }>("/account/memory")
.then((r) => setText(r.memory ?? ""))
.catch((e) => toast(e.message));
}, []);
if (text === undefined) return null;
return (
<div className="panel">
<h3>
<Icon name="brain" /> {t("memory.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("memory.hint")}
</div>
<textarea rows={6} style={{ width: "100%" }} value={text} placeholder={t("memory.placeholder")} onChange={(e) => setText(e.target.value)} />
<div className="row" style={{ marginTop: 8 }}>
<span className="grow" />
<button className="primary" onClick={() => put("/account/memory", { memory: text }).then(() => toast(t("settings.saved"), "success")).catch((e) => toast(e.message))}>
{t("common.save")}
</button>
</div>
</div>
);
}
+23
View File
@@ -388,6 +388,29 @@ export const de: Messages = {
"tokens.noExpiry": "Kein Ablauf",
"tokens.days": "Tage",
"tokens.create": "Token erstellen",
"cli.title": "Terminal-Client",
"cli.intro": "Arbeite mit CodeCraft im Terminal wie mit Claude Code: Der Agent bearbeitet Dateien und führt Befehle auf deinem Rechner aus, Modelle, Modi und Regeln kommen von diesem Server (Kosten erscheinen in deiner Statistik). Er läuft auch ohne Server mit eigenen API-Keys.",
"cli.step1": "Installieren (Linux, x86_64 und arm64):",
"cli.step2": "Mit deinem CodeCraft-Konto anmelden:",
"cli.step3": "Im Projektordner mit `codecraft` starten. Darin: /help, /model, /mode, Shift+Tab wechselt den Modus, @ fügt Dateien hinzu. `codecraft update` aktualisiert.",
"cli.downloads": "Direkte Downloads:",
"memory.title": "Persönliches Gedächtnis",
"memory.hint": "Was jeder Agent über dich wissen soll – in allen Projekten, im Web und im Terminal-Client (Sprache, Code-Stil, bevorzugte Werkzeuge …). Projektwissen liegt pro Projekt in .codecraft/memory.md.",
"memory.placeholder": "z. B. Antworte auf Deutsch. Ich bevorzuge TypeScript und Rust. Nie ohne Rückfrage committen.",
"memory.remembered": "Gemerkt",
"whatsnew.title": "Neu in {version}",
"update.available": "Update {version} verfügbar",
"settings.version": "Version",
"settings.fallback": "Ersatz",
"settings.fallbackHint": "Wird automatisch genutzt, wenn der Host dieses Modells nicht erreichbar oder das Budget des Anbieters aufgebraucht ist",
"install.label": "App",
"install.installed": "Installiert",
"install.button": "CodeCraft als App installieren",
"install.hint.chrome": "Chrome auf Android: Menü ⋮ → „App installieren“ (oder „Zum Startbildschirm hinzufügen“ → Installieren). Wenn nur eine Verknüpfung entsteht, muss der Server aus dem Internet erreichbar sein, damit Google die App bauen kann.",
"install.hint.samsung": "Samsung Internet: Menü → „Seite hinzufügen zu“ → „Startbildschirm“.",
"install.hint.firefox": "Firefox kann keine Web-Apps installieren – öffne CodeCraft in Chrome (Android) oder nutze „Zum Startbildschirm hinzufügen“.",
"install.hint.ios": "Safari: Teilen-Knopf → „Zum Home-Bildschirm“. Push-Benachrichtigungen funktionieren nur in der installierten App.",
"install.hint.desktop": "Chrome/Edge: Installations-Symbol in der Adressleiste oder Menü → „CodeCraft installieren“.",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
+23
View File
@@ -387,6 +387,29 @@ export const en = {
"tokens.noExpiry": "No expiry",
"tokens.days": "days",
"tokens.create": "Create token",
"cli.title": "Terminal client",
"cli.intro": "Work with CodeCraft in your terminal like with Claude Code: the agent edits files and runs commands on your machine, while models, modes and rules come from this server (costs show up in your statistics). It can also run without a server using your own API keys.",
"cli.step1": "Install (Linux, x86_64 and arm64):",
"cli.step2": "Sign in with your CodeCraft account:",
"cli.step3": "Start it in a project folder with `codecraft`. Inside: /help, /model, /mode, Shift+Tab switches the mode, @ adds files. `codecraft update` updates it.",
"cli.downloads": "Direct downloads:",
"memory.title": "Personal memory",
"memory.hint": "What every agent should know about you – in all projects, in the web and in the terminal client (language, code style, tools you prefer …). Project facts are kept per project in .codecraft/memory.md.",
"memory.placeholder": "e.g. Answer in German. I prefer TypeScript and Rust. Never commit without asking.",
"memory.remembered": "Remembered",
"whatsnew.title": "What's new in {version}",
"update.available": "Update {version} available",
"settings.version": "Version",
"settings.fallback": "Fallback",
"settings.fallbackHint": "Used automatically when this model's host is unreachable or its provider budget is used up",
"install.label": "App",
"install.installed": "Installed",
"install.button": "Install CodeCraft as an app",
"install.hint.chrome": "Chrome on Android: menu ⋮ → “Install app” (or “Add to home screen” → Install). If only a shortcut appears, the server must be reachable from the internet for Google to build the app.",
"install.hint.samsung": "Samsung Internet: menu → “Add page to” → “Home screen”.",
"install.hint.firefox": "Firefox can't install web apps – open CodeCraft in Chrome (Android) or use “Add to home screen”.",
"install.hint.ios": "Safari: Share button → “Add to Home Screen”. Push notifications work only in the installed app.",
"install.hint.desktop": "Chrome/Edge: install icon in the address bar or menu → “Install CodeCraft”.",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
+41
View File
@@ -0,0 +1,41 @@
/** PWA installation: keep the browser's install prompt for an in-app button. */
type PromptEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };
let deferred: PromptEvent | null = null;
const listeners = new Set<() => void>();
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferred = e as PromptEvent;
listeners.forEach((l) => l());
});
window.addEventListener("appinstalled", () => {
deferred = null;
listeners.forEach((l) => l());
});
export const canPrompt = () => !!deferred;
export const isInstalled = () => matchMedia("(display-mode: standalone)").matches || (navigator as any).standalone === true;
export function onInstallChange(fn: () => void) {
listeners.add(fn);
return () => void listeners.delete(fn);
}
export async function promptInstall(): Promise<boolean> {
if (!deferred) return false;
await deferred.prompt();
const choice = await deferred.userChoice;
deferred = null;
listeners.forEach((l) => l());
return choice.outcome === "accepted";
}
/** Which manual instructions fit this browser. */
export function platformHint(): "ios" | "firefox" | "samsung" | "chrome" | "desktop" {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) return "ios";
if (/Firefox/.test(ua)) return "firefox";
if (/SamsungBrowser/.test(ua)) return "samsung";
if (/Android/.test(ua)) return "chrome";
return "desktop";
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { registerSW } from "virtual:pwa-register";
import "./install";
import "./styles.css";
import { App } from "./App";
import { applyTheme } from "./theme";
+3
View File
@@ -25,6 +25,8 @@ export interface SessionError {
export interface State {
me: User | null;
/** Newer release (admins). */
update: { version: string; url: string; notes: string } | null;
connected: boolean;
sessions: Session[];
/** Includes subagent sessions loaded on demand. */
@@ -49,6 +51,7 @@ export interface State {
let state: State = {
me: null,
update: null,
connected: false,
sessions: [],
sessionById: {},
+32
View File
@@ -3054,3 +3054,35 @@ button.danger-solid {
word-break: break-all;
font-size: 12px;
}
.cli-steps {
margin: 0 0 10px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.memory-card .icon {
color: var(--purple);
}
.update-chip {
display: flex;
align-items: center;
gap: 6px;
margin: 0 12px 8px;
padding: 6px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 18%, var(--bg1));
color: var(--fg0);
font-size: 13px;
text-decoration: none;
}
.whatsnew {
margin: 10px 0 16px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
+3
View File
@@ -186,6 +186,9 @@ export interface User {
export interface Info {
name: string;
version: string;
commit?: string;
/** Newer release (admins only). */
update?: { version: string; url: string; notes: string } | null;
setup_required: boolean;
oidc: { label: string } | null;
user: User | null;
+19
View File
@@ -0,0 +1,19 @@
/** "What's new" entries per version (shown once after an update). */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"0.1.0": {
en: [
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
"Automations: agents on a schedule (ask an agent in any chat to set one up)",
"Review loops per mode, project memory and personal memory",
"Web terminal, git worktrees, accept changes hunk by hunk, voice input",
"15 colour themes with light/dark/auto, push notifications on your phone",
],
de: [
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client",
"Automationen: Agenten nach Zeitplan (lass sie dir in einem Chat von einem Agenten einrichten)",
"Review-Schleifen pro Modus, Projekt-Gedächtnis und persönliches Gedächtnis",
"Web-Terminal, Git-Worktrees, Änderungen stückweise übernehmen, Spracheingabe",
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy",
],
},
};
+10 -2
View File
@@ -18,16 +18,24 @@ export default defineConfig({
background_color: "#282828",
display: "standalone",
start_url: "/",
id: "/",
scope: "/",
orientation: "any",
categories: ["developer", "productivity"],
icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "icon.svg", sizes: "any", type: "image/svg+xml" },
],
screenshots: [
{ src: "screenshots/wide.png", sizes: "1400x860", type: "image/png", form_factor: "wide", label: "Chat with an agent" },
{ src: "screenshots/narrow.png", sizes: "780x1688", type: "image/png", form_factor: "narrow", label: "CodeCraft on the phone" },
],
},
workbox: {
// The API is always live data; only the app shell is cached.
navigateFallbackDenylist: [/^\/api/],
navigateFallbackDenylist: [/^\/api/, /^\/cli/],
runtimeCaching: [],
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
globIgnores: ["**/occt-import-js*"],