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:
1 parent
aa1f8cf016
commit
6dffcb501a
52 files changed
+6477
-765
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user