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
+3
-1
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user