diff --git a/.air.toml b/.air.toml index c6433520..e4d77344 100644 --- a/.air.toml +++ b/.air.toml @@ -6,7 +6,7 @@ tmp_dir = ".air" [build] cmd = "go build -o ./.air/antares ./cmd/antares" bin = "./.air/antares" - full_bin = "./.air/antares serve --foreground" + full_bin = "./.air/antares --foreground" include_ext = ["go", "yaml", "yml", "md"] exclude_dir = [".air", "web", "dist", ".git", "node_modules", "internal/server/dist"] exclude_regex = ["_test\\.go"] diff --git a/HANDOFF.md b/HANDOFF.md index fd2ede5f..8b40d7f9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -23,7 +23,7 @@ below is done and green unless called out under **Next Steps**. - **Embedded bundle:** `make build-web` builds `web/dist` and copies it to `internal/server/dist` (which is gitignored except `.gitkeep` — keep that file; `make build-web` deletes it, so `touch internal/server/dist/.gitkeep` after). -- **Run the binary:** on macOS use `antares serve --foreground`. Cross-compiled +- **Run the binary:** on macOS use `antares --foreground`. Cross-compiled `CGO_ENABLED=0` darwin binaries get an invalid ad-hoc signature and are SIGKILLed (exit 137) on other machines — fix with `codesign --force --sign -`. - Tests: `GOTOOLCHAIN=go1.26.3 go test ./...` → 32 packages, all green. diff --git a/README.md b/README.md index 7a90209a..0aa59cb2 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,10 @@ Production build: ```bash make build # → bin/antares, dashboard embedded -./bin/antares serve # starts in the background +./bin/antares # starts in the background ``` -Use `antares serve --foreground` when running under systemd, Docker, or while +Use `antares --foreground` when running under systemd, Docker, or while debugging and you want the server attached to the current terminal. Daemon logs are written to `~/.antares/logs/daemon.log`. @@ -344,8 +344,8 @@ gate access behind an allow list or a pairing approval flow. Their tools are namespaced `mcp____` and made available to the model automatically; a server that fails to start is reported, never fatal. -**Two interfaces.** A full-screen terminal UI (`antares`) and a web dashboard -(`antares serve`) over the same agent, sessions, and memory. The TUI has a +**Two interfaces.** A web dashboard (`antares`) and a full-screen terminal UI +(`antares tui`) over the same agent, sessions, and memory. The TUI has a multiline composer, slash-command completion, live tool output, history recall, scrollback, and Ctrl+C interrupt. Run `/help` inside it for the full list. diff --git a/cmd/antares/daemon.go b/cmd/antares/daemon.go index 102dd443..12100232 100644 --- a/cmd/antares/daemon.go +++ b/cmd/antares/daemon.go @@ -159,7 +159,7 @@ func acquireDaemonStartLock(timeout time.Duration) (func(), error) { continue } if time.Now().After(deadline) { - return nil, errors.New("another antares serve command is still starting the daemon") + return nil, errors.New("another antares command is still starting the daemon") } time.Sleep(50 * time.Millisecond) } diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 38b53b72..bfa39470 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -77,6 +77,7 @@ func run() error { switch command { case "serve": + // `serve` stays as a script alias; the documented command is bare `antares`. return cmdServe(args) case "stop": return cmdStop(args) diff --git a/cmd/smokefixture/main.go b/cmd/smokefixture/main.go index 07088623..0b815dac 100644 --- a/cmd/smokefixture/main.go +++ b/cmd/smokefixture/main.go @@ -1,5 +1,5 @@ -// smokefixture seeds an isolated ANTARES_HOME so `bin/antares serve -// --foreground` can boot for smoke testing without touching the developer's +// smokefixture seeds an isolated ANTARES_HOME so `bin/antares --foreground` +// can boot for smoke testing without touching the developer's // real ~/.antares, without dialling any provider, and with the exact session // row the dashboard route-walk expects at /c/. // diff --git a/docs/api.md b/docs/api.md index 766dfe6a..4110ff09 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # HTTP API -`antares serve` exposes a JSON API on `:8787` and serves the dashboard from the +`antares` exposes a JSON API on `:8787` and serves the dashboard from the same port. Everything the dashboard does is available here. ## Authentication diff --git a/docs/backups.md b/docs/backups.md index afa649a4..d9aa65a2 100644 --- a/docs/backups.md +++ b/docs/backups.md @@ -70,7 +70,7 @@ antares backup # on the new one export ANTARES_HOME=/var/lib/antares antares backup restore /path/to/antares-….tar.gz -antares serve +antares ``` Sessions, memory, skills, schedules, and settings all come across. Check the diff --git a/docs/deployment.md b/docs/deployment.md index 5064e55d..31ddc3d9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -67,7 +67,7 @@ ENV ANTARES_HOME=/data \ TOOLS_BROWSER_EXECUTABLE=/usr/bin/chromium VOLUME /data EXPOSE 8787 -CMD ["antares", "serve"] +CMD ["antares", "--foreground"] ``` Chromium is only needed for the browser tool. Leave it out and everything else diff --git a/docs/getting-started.md b/docs/getting-started.md index 34de5ed6..3304e7f9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -58,21 +58,20 @@ both. This only sets the default; either is available at any time. ./bin/antares ``` -That is the terminal interface. Type a message and press Enter. `Alt+Enter` -makes a new line, `Ctrl+C` interrupts a turn and quits on a second press. +That starts the API and dashboard in the background. Open +. The chat there is the same agent over the same +sessions and the same memory as the terminal UI. -Type `/` for the command palette — `/help` lists everything, `/status` shows what -is configured, `/model` changes the model without leaving the conversation. - -For the dashboard instead: +For the terminal interface: ```bash -./bin/antares serve +./bin/antares tui ``` -Then open . The chat there is the same agent over the -same sessions and the same memory; you can start a conversation in one and -continue it in the other. +Type a message and press Enter. `Alt+Enter` makes a new line, `Ctrl+C` +interrupts a turn and quits on a second press. Type `/` for the command +palette — `/help` lists everything, `/status` shows what is configured, +`/model` changes the model without leaving the conversation. ## Reaching it from another machine diff --git a/docs/verification.md b/docs/verification.md index 27a82596..749c4181 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -40,7 +40,7 @@ OPENAI_API_KEY=sk-… go test ./internal/llm -run TestLiveSpeakRoundTrip -v ## Chat gateways Gateways need a running bot and, for the webhook ones, a reachable URL. Verify -each by configuring it, starting `antares serve`, and sending the bot a message — +each by configuring it, starting `antares`, and sending the bot a message — it should reply. | Gateway | What to provide | How it connects | diff --git a/scripts/smoke-run.mjs b/scripts/smoke-run.mjs index 9ed0b0e8..230383b6 100644 --- a/scripts/smoke-run.mjs +++ b/scripts/smoke-run.mjs @@ -3,14 +3,14 @@ // 1. Allocate a fresh temp ANTARES_HOME + workspace so nothing under the // developer's real ~/.antares is read or written. // 2. Bind three loopback ports: -// - antares serve (dashboard + API) +// - antares (dashboard + API) // - openai-compatible provider stub (deterministic /v1/models catalogue // plus an empty non-billing /v1/chat/completions in case something // the walk touches actually issues a request) // 3. Run bin/smokefixture (cmd/smokefixture) to hash the synthetic // password, write config.yaml, and seed the SMOKE_FIXTURE_SESSION_ID // row through the real store.Open (schema stays honest). -// 4. Launch bin/antares serve --foreground with a scrubbed env — only +// 4. Launch bin/antares --foreground with a scrubbed env — only // PATH / HOME / TMPDIR / SHELL / LANG / the ANTARES_* pointers survive; // inherited provider keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, …) never // reach the child, so a stale key on the developer's box cannot silently @@ -237,15 +237,15 @@ async function main() { '--session-id', 'smoke-session', ]) - const server = spawn(ANTARES_BIN, ['serve', '--foreground'], { + const server = spawn(ANTARES_BIN, ['--foreground'], { env: baseEnv, cwd: REPO, stdio: ['ignore', 'inherit', 'inherit'], }) - onExit(terminate(server, 'antares serve')) + onExit(terminate(server, 'antares')) server.on('exit', (code, signal) => { if (!cleaning) { - console.error(`smoke-run: antares serve exited early code=${code} signal=${signal}`) + console.error(`smoke-run: antares exited early code=${code} signal=${signal}`) } }) diff --git a/web/bun.lock b/web/bun.lock index 798c66ef..60b575d5 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,7 +5,10 @@ "": { "name": "antares-web", "dependencies": { + "@fontsource-variable/cormorant-garamond": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/montserrat": "^5.3.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -138,8 +141,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@fontsource-variable/cormorant-garamond": ["@fontsource-variable/cormorant-garamond@5.3.0", "", {}, "sha512-rgZJ/5w3hi60swAPnrOXpZf5wEH6NTrvm4H8D9Y4b1QIFUqlX14eM3QTMU/eAznjfh0eXpO1QUqsFhiy58aoMg=="], + "@fontsource-variable/ibm-plex-sans": ["@fontsource-variable/ibm-plex-sans@5.3.0", "", {}, "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + + "@fontsource-variable/montserrat": ["@fontsource-variable/montserrat@5.3.0", "", {}, "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/web/index.html b/web/index.html index 5a60c1ed..a5db26d8 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + @@ -9,7 +9,31 @@ - + + diff --git a/web/package.json b/web/package.json index c22fe02e..68c956aa 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,10 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@fontsource-variable/cormorant-garamond": "^5.3.0", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/montserrat": "^5.3.0", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/web/src/App.tsx b/web/src/App.tsx index e40d78ef..9cae87eb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { LoginGate } from '@/components/layout/LoginGate' import { ROUTES } from '@/lib/routes' import { allRedirects } from '@/lib/routeManifest' import { I18nProvider } from '@/lib/i18n' +import { ThemeProvider } from '@/lib/theme-provider' // Onboarding renders standalone: there is no sidebar to wander off into before // the agent can answer anything. @@ -41,59 +42,61 @@ const REDIRECTS = allRedirects() export function App() { return ( - - - - - - - } - /> - - - - } - /> - {/* Dev-only prototype routes, standalone (no chrome, no gates). */} - {sampleRoutes.map(({ name, Comp }) => ( + + + + - + } /> - ))} + + + + } + /> + {/* Dev-only prototype routes, standalone (no chrome, no gates). */} + {sampleRoutes.map(({ name, Comp }) => ( + + + + } + /> + ))} - {/* LoginGate holds everything behind the dashboard password (when + {/* LoginGate holds everything behind the dashboard password (when set); SetupGate redirects a fresh install to onboarding; AppShell owns the page container, header, and Suspense boundary, so every route inside renders with identical chrome. */} - }> - }> - }> - {ROUTES.flatMap(({ path, aliases, component: Component }) => - [path, ...(aliases ?? [])].map((p) => ( - } /> - )), - )} - {REDIRECTS.map(({ from, to }) => ( - } /> - ))} - } /> + }> + }> + }> + {ROUTES.flatMap(({ path, aliases, component: Component }) => + [path, ...(aliases ?? [])].map((p) => ( + } /> + )), + )} + {REDIRECTS.map(({ from, to }) => ( + } /> + ))} + } /> + - - - - + + + + ) } diff --git a/web/src/components/chat/ChatTranscript.tsx b/web/src/components/chat/ChatTranscript.tsx index 0400c75d..490d8458 100644 --- a/web/src/components/chat/ChatTranscript.tsx +++ b/web/src/components/chat/ChatTranscript.tsx @@ -1,23 +1,27 @@ -import { memo, useEffect, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { ArrowClockwise, + ArrowUp, Brain, CaretDown, Check, Copy, FileText, + FileX, PencilSimple, Terminal, Warning, } from '@phosphor-icons/react' +import { get } from '@/lib/api' import { copyText } from '@/lib/clipboard' import { useI18n, useTimeAgo } from '@/lib/i18n' import { cn } from '@/lib/utils' +import { shouldCancelEditOnPointer, shouldFadeStickyPrompt, nextPromptLineCount, stickyPromptClipClass, type ChatMessage } from '@/lib/chatTranscript' import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/primitives' import { Markdown } from '@/components/chat/Markdown' import { ToolCallCard } from '@/components/chat/ToolCallCard' import { AskUserCard } from '@/components/chat/AskUserCard' -import type { ChatMessage } from '@/lib/chatTranscript' /** * Collapsible model-thinking block. @@ -83,8 +87,8 @@ const ReasoningBlock = memo(function ReasoningBlock({ text }: { text: string }) // the same message changes during streaming. const TextSegment = memo(function TextSegment({ text }: { text: string }) { return ( -
- +
+
) }) @@ -165,6 +169,253 @@ export function AssistantErrorBlock({ ) } +interface FileChange { + path: string + externally_changed: boolean + will_delete: boolean +} + +function autosize(el: HTMLTextAreaElement | null) { + if (!el) return + el.style.height = '0px' + el.style.height = `${el.scrollHeight}px` +} + +/** Inline user-prompt editor. Enter inserts a newline; only the send button + * (or ⌘/Ctrl+Enter) submits, so a mid-edit Return does not re-send. */ +function InlineUserEditor({ + sessionId, + messageId, + initialText, + onSubmit, + onCancel, +}: { + sessionId?: string + messageId: string + initialText: string + onSubmit: (text: string, revert: boolean) => void + onCancel: () => void +}) { + const { t } = useI18n() + const ref = useRef(null) + const rootRef = useRef(null) + const [text, setText] = useState(initialText) + const [revert, setRevert] = useState(true) + const [changes, setChanges] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setText(initialText) + setRevert(true) + setChanges([]) + requestAnimationFrame(() => { + autosize(ref.current) + ref.current?.focus() + const el = ref.current + if (el) el.setSelectionRange(el.value.length, el.value.length) + }) + if (!sessionId || !messageId) return + setLoading(true) + get<{ changes: FileChange[] }>( + `/sessions/${sessionId}/edit-preview?message_id=${encodeURIComponent(messageId)}`, + ) + .then((d) => setChanges(d.changes ?? [])) + .catch(() => setChanges([])) + .finally(() => setLoading(false)) + }, [sessionId, messageId, initialText]) + + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 0) return + if (shouldCancelEditOnPointer(e.target instanceof Node ? e.target : null, rootRef.current)) onCancel() + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [onCancel]) + + const revertable = changes.filter((c) => !c.externally_changed) + const external = changes.filter((c) => c.externally_changed) + const send = () => { + const next = text.trim() + if (!next) return + onSubmit(next, revert && revertable.length > 0) + } + + return ( +
+