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

+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>
);
}