From dd58e98068494c00988a3a5d940f9b4a7bc380b5 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Tue, 2 Jun 2026 17:33:52 +0800 Subject: [PATCH] Switch default chat to TUI v2 --- README.md | 19 +- docs/README.md | 2 +- docs/guides/chat-and-sessions.md | 7 + docs/guides/development.md | 11 +- docs/guides/runtime-host-model.md | 314 +++++++----------- docs/reference/capabilities.md | 1 + docs/reference/cli.md | 5 + package.json | 6 +- .../unit/cli-v2/chat-v2-runtime.test.ts | 17 +- .../unit/cli/main-command-routing.test.ts | 12 +- .../unit/control-plane/static-assets.test.ts | 40 +++ src/cli-v2/commands/chat-v2-command.ts | 17 +- src/cli/main.ts | 23 +- src/server/lifecycle.ts | 20 +- src/server/static.ts | 10 +- src/web-v2/index.html | 10 +- src/web-v2/layout/AppFrameShared.tsx | 2 +- src/web-v2/layout/MobileAppFrame.tsx | 2 +- src/web-v2/public/manifest.webmanifest | 19 ++ src/web-v2/styles/shell.css | 9 + 20 files changed, 301 insertions(+), 245 deletions(-) create mode 100644 src/__tests__/unit/control-plane/static-assets.test.ts create mode 100644 src/web-v2/public/manifest.webmanifest diff --git a/README.md b/README.md index 0956f182..826dfcc9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Heddle is an open-source AI coding agent runtime and terminal-first workspace fo Official website: [heddleagent.com](https://heddleagent.com) -> **New web and mobile control plane is out.** Run `heddle daemon` to try the new browser UI for sessions, tasks, workspace switching, memory status, and mobile review. [See the browser control plane](#browser-control-plane-overview). +> **Terminal UI v2 is now the default.** Run `heddle` or `heddle chat` to try the API-backed terminal experience. Messages, run events, and agent response streams now flow through the shared control-plane path, so terminal, browser, and mobile clients can follow the same work at the same time for smoother cross-device workflows. If you need the old terminal UI while the transition settles, run `heddle chat-v1`. It is designed for workflows where an agent needs to inspect a live repository, make bounded changes, verify results, keep continuity across sessions, and stay observable to the operator. Heddle supports OpenAI and Anthropic models, stores local workspace state under `.heddle/`, includes both a terminal chat experience and a browser control plane, learns durable workspace knowledge while it works, and gives users a review path for file diffs, commands, approvals, and traces. @@ -213,11 +213,11 @@ The terminal composer supports multiline prompts, prompt undo/redo, prompt histo More: [Chat and sessions guide](docs/guides/chat-and-sessions.md) -### Terminal UI v2 status +### Terminal UI v2 is the default -`cli-v2` is actively under development, but it is not released as the default terminal experience yet. The current work is intentionally architectural: the new TUI consumes the same local control-plane API as the browser UI and does not reach directly into core services. +The default terminal chat is now the API-backed `cli-v2` experience. Run `heddle` or `heddle chat` from a project to start it. The legacy terminal UI remains available as an explicit fallback through `heddle chat-v1` while the transition settles. -For users, the goal is one behavior model across interfaces. A saved conversation, selected model, reasoning setting, approval state, and live run status should be the same whether you are looking from the terminal, the browser control plane, or another device connected to the same local daemon. Conversation state is inherently synchronized because each interface is a client of the same session/control-plane path rather than a separate runtime. +The v2 terminal UI consumes the same local control-plane API as the browser UI and does not reach directly into core services. For users, the goal is one behavior model across interfaces. A saved conversation, selected model, reasoning setting, approval state, and live run status should be the same whether you are looking from the terminal, the browser control plane, or another device connected to the same local control-plane server. Messages, tool events, approval waits, and streamed agent responses are delivered through the shared session event path, so multiple devices can observe and continue the same work simultaneously instead of waiting for one surface to finish or refresh. For contributors, the goal is a cleaner implementation path: TUI-specific rendering and keyboard behavior stay in `cli-v2`, shared semantics stay in core and server-owned control-plane APIs, and future advanced features can build on a maintainable API-first foundation instead of duplicating command/session logic in each interface. @@ -298,13 +298,10 @@ Heddle is local-first, but it still has a runtime ownership model. The short version is: -- the workspace is the project-level state and ownership unit -- one workspace should have one live runtime owner at a time -- that owner is either: - - the embedded CLI command you started - - or a background `heddle daemon` - -This is why Heddle stores state under the workspace’s `.heddle/`, why the browser control plane acts as a client of the daemon rather than a separate runtime, and why the UI treats workspace switching as choosing which local `.heddle/` state to inspect and operate. +- Heddle uses one local control-plane server path for interactive chat and browser control. +- `heddle` / `heddle chat` attach to a live control-plane server when one exists, or start an embedded one when needed. +- `heddle daemon` starts the same server path as a standalone process for browser and longer-lived workflows. +- Workspaces own their local `.heddle/` state, but the server does not own one global active workspace. Clients send workspace identity with workspace-scoped requests. If you want to understand how `chat`, `ask`, the daemon, the control plane, and workspace-local state fit together, read: diff --git a/docs/README.md b/docs/README.md index d208a964..df35c2d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ context first, then branch to deeper material only when needed. If you are new to Heddle, begin with: - [Root README](../README.md) for installation and product overview -- [Runtime host model](guides/runtime-host-model.md) for how workspace ownership and daemon mode work +- [Runtime host model](guides/runtime-host-model.md) for how terminal chat, daemon mode, workspace identity, and the shared control-plane server fit together - [Chat and sessions](guides/chat-and-sessions.md) for the core interactive workflow - [Knowledge persistence](guides/knowledge-persistence.md) for how Heddle learns durable workspace knowledge while it works - [Control plane](guides/control-plane.md) for the browser UI, workspace switching, session review, task workbench, and browser composer diff --git a/docs/guides/chat-and-sessions.md b/docs/guides/chat-and-sessions.md index 59bb969c..707c0363 100644 --- a/docs/guides/chat-and-sessions.md +++ b/docs/guides/chat-and-sessions.md @@ -41,6 +41,13 @@ heddle --cwd /path/to/project heddle chat --model gpt-5.4-mini --max-steps 20 ``` +`heddle` and `heddle chat` start the API-backed terminal UI. If you need the +legacy terminal UI while the v2 transition settles, use: + +```bash +heddle chat-v1 +``` + Heddle uses the current directory as the workspace root unless you pass `--cwd`. At startup, Heddle also looks for one project instruction file. The default priority is `HEDDLE.md`, then `AGENTS.md`, then `CLAUDE.md`; the first non-empty file is appended to the system prompt. Set `agentContextPaths` in `heddle.config.json` only when a project needs custom paths or multiple instruction files. diff --git a/docs/guides/development.md b/docs/guides/development.md index 93a46339..804667f1 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -95,6 +95,13 @@ yarn chat:dev `yarn chat:dev` runs the same source CLI entry point as the packaged `heddle chat` command. The `examples/` directory is reserved for programmatic host/runtime examples rather than the main terminal chat UI. +`yarn chat:dev` uses the API-backed terminal UI. The legacy terminal UI remains +available as an explicit fallback: + +```bash +yarn chat:dev:v1 +``` + If you prefer provider-specific local shortcuts, the repository also includes convenience scripts such as: ```bash @@ -115,13 +122,13 @@ yarn client:dev The daemon-backed backend runs on `127.0.0.1:8765` and the Vite client runs on `127.0.0.1:5173`. -`yarn daemon:dev` uses the real daemon path, including workspace ownership, daemon registry refreshes, and built default control-plane static assets from `dist/src/web-v2`. This is the closest development path to the shipped `heddle daemon` behavior. +`yarn daemon:dev` uses the real daemon path, including live server registry refreshes and built default control-plane static assets from `dist/src/web-v2`. This is the closest development path to the shipped `heddle daemon` behavior. `yarn client:dev:v1` remains available for the legacy v1 control plane and defaults to `127.0.0.1:5174`. Because it serves built assets, run `yarn build` after frontend changes before relying on `yarn daemon:dev` for UI validation. -`yarn server:dev` remains a lighter backend-only path for server work. It starts the Express/tRPC app directly and does not register daemon ownership, so the clients will read that path as a local control-plane session rather than a daemon-owned workspace. +`yarn server:dev` remains a lighter backend-only path for server work. It starts the Express/tRPC app directly for API development rather than running the full daemon command wrapper. For a production-style local run of the built daemon: diff --git a/docs/guides/runtime-host-model.md b/docs/guides/runtime-host-model.md index 2f6fd5b3..64921820 100644 --- a/docs/guides/runtime-host-model.md +++ b/docs/guides/runtime-host-model.md @@ -1,264 +1,194 @@ # Runtime Host Model -Heddle is local-first, but it is not purely stateless. +Heddle is local-first, but interactive clients are no longer separate runtimes +that each invent their own workspace ownership rules. -To use Heddle well, it helps to understand one design rule: +The current model is: -> A workspace can have only one live runtime owner at a time. +> One local control-plane server path, many clients, explicit workspace identity +> per request. -This guide explains what that means in practice, why Heddle works that way, and how to reason about terminal chat, `ask`, the daemon, and the browser control plane. +This guide explains how terminal chat, `ask`, the daemon, and the browser +control plane fit together. ## The Short Version -Heddle has two ways to run: +Heddle has three important pieces: -- `embedded` mode: the CLI command you started owns execution directly -- `daemon` mode: a background Heddle daemon owns execution for that workspace +- **Workspace state**: the project-local `.heddle/` directory that stores + sessions, traces, memory, uploads, heartbeat tasks, and run records. +- **Control-plane server**: the local HTTP/tRPC/SSE server that exposes shared + chat/session/workspace behavior to terminal and browser clients. +- **Client active workspace**: the workspace selected by a terminal or browser + client. Workspace-scoped requests carry that workspace identity to the server. -The ownership unit is the workspace, not your whole machine. +The server should not be understood as owning one global active workspace. +Clients choose a workspace, send `workspaceId`, and the server resolves the +request workspace at the API boundary. -That means: - -- one repo or project root can run embedded -- another workspace can be daemon-owned at the same time -- but one workspace should not have two live owners mutating state in parallel - -## What Counts As A Workspace - -A Heddle workspace is the local project scope Heddle is operating on. - -In the simplest case, it is just the directory you started Heddle from. - -For users, a workspace is the project folder plus its `.heddle/` state. Internally, Heddle also tracks lower-level fields such as the workspace path, state path, workspace id, and optional repo roots, but the user-facing concept should stay simple: choose a workspace, and Heddle reads that workspace's state. - -By default, Heddle stores workspace-local state under: - -```text -/.heddle/ -``` - -That includes things like: - -- saved chat sessions -- heartbeat tasks and run history -- memory notes -- traces -- workspace metadata - -This is intentional. Heddle is designed so the workspace keeps its own operational state rather than hiding it in a hosted service. +## How Chat Starts -## Why Heddle Uses Ownership - -Without workspace ownership, two Heddle processes could both believe they are in charge of the same workspace. - -That creates bad failure modes: - -- two processes editing or checkpointing the same session state -- heartbeat tasks being triggered by multiple hosts -- the browser control plane showing one view while a different embedded process mutates local state behind its back -- no clear answer to which process should handle approvals, recovery, or background work - -Heddle avoids that by treating runtime ownership as singular per workspace. - -## Embedded Mode - -Embedded mode is the zero-setup path. - -Examples: +The default terminal chat is the API-backed TUI v2 path: ```bash heddle heddle chat -heddle ask "summarize this repo" ``` -In embedded mode: +When chat starts, it checks for a live local control-plane server: + +- if a live server exists, chat attaches to it; +- if no live server exists, chat starts an embedded control-plane server in the + same process; +- either way, the TUI talks to the shared control-plane API after bootstrap. -- the command you started owns execution -- state is still written under the workspace’s `.heddle/` -- no daemon is required -- this is the normal path for direct terminal use when no daemon already owns that workspace +The legacy terminal UI remains available as an explicit fallback: -This is the simplest mental model: +```bash +heddle chat-v1 +``` -- you run a command -- that command is the runtime host +Use that only when you need to compare behavior during the v2 transition. ## Daemon Mode -Daemon mode is the background-owner path. - -Example: +The daemon starts the same control-plane server path as a standalone process: ```bash heddle daemon ``` -In daemon mode: - -- the daemon becomes the runtime owner for that workspace -- the browser control plane reads and mutates daemon-owned runtime state -- this is the right shape for longer-lived background and remote oversight workflows - -The daemon is especially useful when you want: - -- a browser control plane -- remote access from another device -- one stable owner for sessions and tasks while the workstation keeps running - -## The Control Plane Is A Client, Not A Separate Runtime - -The browser control plane is not meant to be a second independent host. +Use daemon mode when you want: -Its job is to act as an operator surface for the runtime owner. +- the browser control plane; +- a longer-lived local server; +- browser or mobile oversight while terminal clients come and go; +- workspace switching from the browser UI. -When the daemon is running: +If a live control-plane server already exists, the daemon command should attach +to that fact, print the existing server address, and exit successfully instead +of starting a competing server. -- the web UI talks to the daemon -- the daemon is the host that owns the workspace +## Browser Control Plane -This is why the browser UI should be understood as a view into the active runtime, not as a different execution model. +The browser control plane is a client of the local control-plane server. It is +not a second independent runtime. -## What Happens When A Daemon Already Owns A Workspace +The browser keeps its own selected workspace state. When you switch workspace +in the browser, browser requests carry that workspace identity. A terminal TUI +launched from another directory can use a different active workspace against the +same live server. -If a live daemon already owns the workspace, Heddle will avoid starting a conflicting embedded owner by default. +That is the intended shape: -Today, that means: +- one local server path; +- separate client active workspace state; +- request-scoped workspace resolution on the server. -- `heddle chat` is blocked from starting embedded against the same workspace -- mutating `heddle heartbeat ...` commands are blocked -- starting a second daemon is blocked -- `heddle ask` can attach to the live daemon instead of failing - -This is deliberate. Heddle prefers one clear owner over silent split-brain behavior. - -## What `heddle ask` Does - -`ask` is currently the first CLI path that can attach to a daemon-owned workspace. - -That means if a live daemon already owns the workspace: - -- a stateless `heddle ask "..."` runs through the daemon -- a session-backed `heddle ask --session ...` or `--new-session ...` also runs through the daemon - -So `ask` behaves like a useful one-shot client of the active runtime host. - -This is different from `chat`, which still remains an embedded interactive surface today. - -## The Daemon Registry - -Heddle keeps a small user-level registry so commands and the control plane can discover active workspace ownership and recently seen workspaces. - -Conceptually, that registry tracks: - -- known workspaces -- active daemon owner metadata -- endpoint and last-seen timestamps - -Its job is coordination and discovery. It lets the browser control plane offer a workspace switcher and reopen projects that were previously opened from the CLI or daemon. - -It is not the main source of truth for workspace history. - -The authoritative operational state still lives with the workspace under `.heddle/`. - -## Mental Model For Real Use - -When deciding how to run Heddle, use this rule of thumb: - -### Use embedded mode when: - -- you want direct terminal chat -- you are working locally in one shell -- you do not need the daemon or browser control plane for that workspace - -### Use daemon mode when: - -- you want the browser control plane -- you want a stable background owner for that workspace -- you want to inspect or operate Heddle from another device -- you want to switch the browser control plane between local workspaces - -### Do not think of daemon mode as “extra UI” - -It is not just a web wrapper around the same terminal process. +## What Counts As A Workspace -It is a different host mode: +A Heddle workspace is the local project scope Heddle is operating on. -- same runtime core -- different live owner +In the simplest case, it is the directory you started Heddle from. For users, a +workspace is the project folder plus its `.heddle/` state. -That is the key distinction. +By default, Heddle stores workspace-local state under: -## Why State Lives In The Workspace +```text +/.heddle/ +``` -Heddle stores state under the workspace on purpose. +That includes: -That gives you: +- saved chat sessions; +- heartbeat tasks and run history; +- memory notes; +- traces; +- uploads; +- workspace metadata. -- readable local state -- portable project history -- no required hosted backend -- easier debugging and inspection +This is intentional. Heddle is designed so operational state stays readable and +local to the project rather than hidden in a hosted service. -It also means the runtime host model stays tied to the actual project rather than to a centralized hidden store. +## Concurrency Model -## Current Limitations +The old mental model was "one workspace has one live runtime owner." The newer +control-plane model is more precise: -The host model is mostly done as a runtime model. +- a local machine should have one live control-plane server; +- many clients can attach to that server; +- workspace-scoped operations resolve their target from request workspace + identity; +- same-session writes are still protected to avoid concurrent mutation. -Important current limits: +Chat safety is centered on the session, not on blocking every second client in +the same workspace. -- `chat` does not yet attach to daemon-owned sessions -- if a daemon already owns the workspace, browser and mobile clients are just clients of that daemon; switching devices is normal and does not require takeover -- browser/mobile do not currently expose host-action controls such as takeover or force-embed, because those actions are not yet useful enough without a stronger ownership policy behind them -- same-session conflict protection is now a soft session lease, not a full takeover system yet -- workspace switching changes which workspace state the control plane is showing; it does not merge or centralize the `.heddle/` state from multiple projects +That means: -So the correct mental model is: +- different sessions in the same workspace can be used from different clients; +- terminal chat, browser, mobile, and `ask` can coexist when they use the shared + control-plane/session path; +- the risky case is multiple live writers touching the same session. -- one workspace -- one live owner -- many clients can observe and operate through that owner +Heddle records a lightweight session lease while a session is being mutated. If +another client tries to continue that same session while the lease is fresh, the +run is blocked with a warning about concurrent mutation risk. -What remains is mostly around special-case ownership transitions, not the normal desktop/mobile workflow. +## What `heddle ask` Does -## Session Concurrency +`ask` is a one-shot terminal command. It still exits after one prompt, but the +run is stored as a saved session under `.heddle/` so traces, memory maintenance, +and later inspection use the same persisted conversation path as session-backed +work. -Chat safety is now centered on the session, not the whole workspace. +The direction for `ask` is to stay aligned with the shared chat/session model so +one-shot runs and interactive TUI runs do not diverge in session semantics, +approvals, workspace identity, or persistence. -That means: +## Management Commands -- different sessions in the same workspace can be used from different clients -- desktop web, mobile web, `ask`, and embedded TUI can coexist -- the risky case is multiple live writers touching the same session +Not every command needs to be an API client. -Heddle now records a lightweight session lease while a session is being mutated. If another client tries to continue that same session while the lease is still fresh, the run is blocked with a warning about concurrent mutation risk. +Local management commands such as memory, auth, init, heartbeat management, and +eval may call documented core/domain service contracts directly when they are +true adapters: -This is intentionally a soft coordination mechanism: +- parse flags; +- call a public service contract; +- format output. -- it prevents the main same-session corruption case -- it does not yet implement rich takeover, transfer, or presence UI +They should not duplicate core policy, storage semantics, validation, fallback +logic, or workspace resolution in command-specific code. ## Practical Rules If you want a simple operational checklist: -1. Start `heddle` or `heddle chat` when you want direct terminal interaction and no daemon owns that workspace. -2. Start `heddle daemon` when you want the browser control plane or a stable background owner. -3. Use the control plane workspace switcher and `Settings > Workspace` to register, rename, and switch between local project workspaces. -4. Treat the workspace as having one live owner at a time. -5. Use `heddle ask` as a lightweight client when a daemon already owns the workspace. -6. Do not assume the web UI, TUI, and daemon are separate runtimes operating independently on the same workspace. +1. Start `heddle` or `heddle chat` for the default API-backed terminal UI. +2. Use `heddle chat-v1` only as a temporary legacy fallback. +3. Start `heddle daemon` when you want the browser control plane or a + longer-lived local server. +4. Use the browser workspace switcher and `Settings > Workspace` to register, + rename, and switch local project workspaces. +5. Treat workspace state as local to `.heddle/`, even when several clients are + attached to the same local control-plane server. +6. Do not assume the web UI, TUI, and daemon are separate runtimes operating + independently on the same workspace. ## Current Product Boundary -For normal use, the current host model should be understood this way: +For normal use, the current model should be understood this way: -- start `heddle daemon` when you want browser access or multi-device access -- use desktop web, mobile web, and `ask` as clients of that daemon -- do not expect browser/mobile to take over or force embedded mode yet +- `heddle` and `heddle chat` are the default terminal chat experience; +- `heddle daemon` gives you the browser control plane and a stable local server; +- browser and terminal clients use the same control-plane/session path; +- workspace switching chooses which local workspace state a client is operating + on; +- the legacy terminal UI is an explicit fallback, not the default path. -That means the absence of host-action controls in the browser is intentional for now. The useful path today is one stable daemon owner with multiple clients, not ownership switching from the UI. +Host-action controls such as takeover or force-embed are intentionally limited +while the workspace-agnostic server model continues to settle. ## Related Guides diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index a1322d42..285e8c7b 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -40,6 +40,7 @@ Heddle can also use: Current runtime features include: - multi-turn chat sessions with saved history under `.heddle/` +- API-backed terminal chat through the same control-plane session path as the browser UI - session management with create, switch, continue, rename, and close flows - automatic conversation compaction for longer chats - manual `/compact` support when an operator wants to shrink session history immediately diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6e5e2c6a..70f0d412 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -7,6 +7,7 @@ This page is a command lookup for the current Heddle CLI surface. ### Chat and one-shot use - `heddle` or `heddle chat`: start interactive chat mode in the current workspace +- `heddle chat-v1`: start the legacy terminal UI fallback - `heddle ask ""`: run a single prompt and exit, backed by a one-off saved session ### Control plane @@ -94,6 +95,9 @@ heddle heartbeat start --every 30m ## Interactive Chat Commands +`heddle` and `heddle chat` start the API-backed terminal UI. The legacy +terminal UI remains available as an explicit fallback through `heddle chat-v1`. + Inside `heddle` / `heddle chat`, the most-used local commands are: - `/model`: show the active model @@ -116,6 +120,7 @@ Inside this repository, common development commands include: ```bash yarn cli:dev yarn chat:dev +yarn chat:dev:v1 yarn daemon:dev yarn daemon:dev:v1 yarn server:dev diff --git a/package.json b/package.json index 3fa0e2b1..1b5d66d2 100644 --- a/package.json +++ b/package.json @@ -63,13 +63,13 @@ "test:browser-integration:install": "playwright install chromium", "cli:dev": "tsx --no-cache src/cli/main.ts", "chat:dev": "tsx --no-cache src/cli/main.ts chat", - "chat:dev:v1": "tsx --no-cache src/cli/main.ts chat", + "chat:dev:v1": "tsx --no-cache src/cli/main.ts chat-v1", "chat:dev:v2": "tsx --no-cache src/cli/main.ts chat-v2", "chat:dev:openai": "OPENAI_API_KEY=\"$PERSONAL_OPENAI_API_KEY\" tsx --no-cache src/cli/main.ts chat", - "chat:dev:v1:openai": "OPENAI_API_KEY=\"$PERSONAL_OPENAI_API_KEY\" tsx --no-cache src/cli/main.ts chat", + "chat:dev:v1:openai": "OPENAI_API_KEY=\"$PERSONAL_OPENAI_API_KEY\" tsx --no-cache src/cli/main.ts chat-v1", "chat:dev:v2:openai": "OPENAI_API_KEY=\"$PERSONAL_OPENAI_API_KEY\" tsx --no-cache src/cli/main.ts chat-v2", "chat:dev:anthropic": "ANTHROPIC_API_KEY=\"$PERSONAL_ANTHROPIC_API_KEY\" tsx --no-cache src/cli/main.ts chat", - "chat:dev:v1:anthropic": "ANTHROPIC_API_KEY=\"$PERSONAL_ANTHROPIC_API_KEY\" tsx --no-cache src/cli/main.ts chat", + "chat:dev:v1:anthropic": "ANTHROPIC_API_KEY=\"$PERSONAL_ANTHROPIC_API_KEY\" tsx --no-cache src/cli/main.ts chat-v1", "chat:dev:v2:anthropic": "ANTHROPIC_API_KEY=\"$PERSONAL_ANTHROPIC_API_KEY\" tsx --no-cache src/cli/main.ts chat-v2", "example:repo-investigator": "tsx --no-cache examples/repo-investigator.ts", "example:conversation-engine": "tsx --no-cache examples/conversation-engine.ts", diff --git a/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts b/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts index 28364dd9..e8655f99 100644 --- a/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts +++ b/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts @@ -4,6 +4,12 @@ import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js'; import type { HeddleControlPlaneServerHandle } from '@/server/index.js'; describe('chat-v2 runtime bootstrap', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + it('attaches to a fresh live control-plane server', async () => { const startServer = vi.fn(); const runtime = await resolveChatV2Runtime({ @@ -12,7 +18,7 @@ describe('chat-v2 runtime bootstrap', () => { preferApiKey: false, forceOwnerConflict: false, runtimeHost: freshRuntimeHost, - }, { startServer }); + }, { startServer, createLogger: () => logger as never }); expect(startServer).not.toHaveBeenCalled(); expect(runtime).toMatchObject({ @@ -41,7 +47,7 @@ describe('chat-v2 runtime bootstrap', () => { kind: 'none', registryPath: '/registry.json', }, - }, { startServer }); + }, { startServer, createLogger: () => logger as never }); expect(startServer).toHaveBeenCalledWith(expect.objectContaining({ mode: 'embedded-chat', @@ -49,8 +55,8 @@ describe('chat-v2 runtime bootstrap', () => { stateRoot: '/repo/.heddle-test', preferApiKey: true, host: '127.0.0.1', - port: 0, - serveAssets: false, + port: 8765, + logger, })); expect(runtime).toMatchObject({ kind: 'embedded', @@ -59,6 +65,7 @@ describe('chat-v2 runtime bootstrap', () => { }); await runtime.close(); expect(close).toHaveBeenCalledTimes(1); + expect(formatChatV2RuntimeNotice(runtime)).toContain('browser=http://127.0.0.1:8123'); }); it('starts embedded when force-owner-conflict bypasses a live server', async () => { @@ -74,7 +81,7 @@ describe('chat-v2 runtime bootstrap', () => { preferApiKey: false, forceOwnerConflict: true, runtimeHost: freshRuntimeHost, - }, { startServer }); + }, { startServer, createLogger: () => logger as never }); expect(startServer).toHaveBeenCalledTimes(1); expect(runtime.kind).toBe('embedded'); diff --git a/src/__tests__/unit/cli/main-command-routing.test.ts b/src/__tests__/unit/cli/main-command-routing.test.ts index 470c5a21..204c4c9f 100644 --- a/src/__tests__/unit/cli/main-command-routing.test.ts +++ b/src/__tests__/unit/cli/main-command-routing.test.ts @@ -3,10 +3,18 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; describe('CLI command routing', () => { - it('keeps explicit chat-v2 command out of the ask shortcut', () => { + it('routes the default chat command to cli-v2 and keeps the v1 escape hatch', () => { const source = readFileSync(join(process.cwd(), 'src', 'cli', 'main.ts'), 'utf8'); + expect(source).toMatch(/\.command\('chat'\)[\s\S]*?await runChatCliV2Command\(resolved\);/); + expect(source).toMatch(/\.command\('chat-v1'\)[\s\S]*?startChatCli\(\{/); expect(source).toContain(".command('chat-v2')"); - expect(source).toMatch(/return \[[^\]]*'chat-v2'[^\]]*\]\.includes\(command\)/s); + expect(source).toMatch(/program\s*\n\s*\.action\([\s\S]*?await runChatCliV2Command\(resolved\);/); + }); + + it('keeps explicit chat commands out of the ask shortcut', () => { + const source = readFileSync(join(process.cwd(), 'src', 'cli', 'main.ts'), 'utf8'); + + expect(source).toMatch(/return \[[^\]]*'chat-v1'[^\]]*'chat-v2'[^\]]*\]\.includes\(command\)/s); }); }); diff --git a/src/__tests__/unit/control-plane/static-assets.test.ts b/src/__tests__/unit/control-plane/static-assets.test.ts new file mode 100644 index 00000000..8102142b --- /dev/null +++ b/src/__tests__/unit/control-plane/static-assets.test.ts @@ -0,0 +1,40 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveDefaultAssetsDir } from '@/server/lifecycle.js'; +import { assertWebAssetsBuilt, isWebAssetsBuilt } from '@/server/static.js'; + +describe('control-plane static assets', () => { + it('prefers built web assets over Vite source assets when running from source', () => { + const root = createTempProject('heddle-static-assets-'); + const sourceAssetsDir = join(root, 'src', 'web-v2'); + const builtAssetsDir = join(root, 'dist', 'src', 'web-v2'); + writeIndex(sourceAssetsDir); + writeIndex(builtAssetsDir); + mkdirSync(join(builtAssetsDir, 'assets'), { recursive: true }); + + expect(resolveDefaultAssetsDir({ + moduleDir: join(root, 'src', 'server'), + env: {}, + })).toBe(builtAssetsDir); + }); + + it('does not treat Vite source HTML as built static assets', () => { + const root = createTempProject('heddle-static-source-'); + const sourceAssetsDir = join(root, 'src', 'web-v2'); + writeIndex(sourceAssetsDir); + + expect(isWebAssetsBuilt(sourceAssetsDir)).toBe(false); + expect(() => assertWebAssetsBuilt(sourceAssetsDir)).toThrow('Built web assets not found'); + }); +}); + +function createTempProject(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function writeIndex(assetsDir: string) { + mkdirSync(assetsDir, { recursive: true }); + writeFileSync(join(assetsDir, 'index.html'), '
'); +} diff --git a/src/cli-v2/commands/chat-v2-command.ts b/src/cli-v2/commands/chat-v2-command.ts index beb71f84..e2f7518a 100644 --- a/src/cli-v2/commands/chat-v2-command.ts +++ b/src/cli-v2/commands/chat-v2-command.ts @@ -1,11 +1,11 @@ import { resolve } from 'node:path'; import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js'; import type { HeddleControlPlaneServerHandle, HeddleControlPlaneServerOptions } from '@/server/index.js'; -import { startHeddleControlPlaneServer } from '@/server/index.js'; +import { createServerLogger, startHeddleControlPlaneServer } from '@/server/index.js'; import { startChatCliV2 } from '../index.js'; const DEFAULT_CONTROL_PLANE_HOST = '127.0.0.1'; -const EMBEDDED_CONTROL_PLANE_PORT = 0; +const DEFAULT_CONTROL_PLANE_PORT = 8765; export type ChatCliV2CommandOptions = { workspaceRoot: string; @@ -41,6 +41,7 @@ export type ChatV2Runtime = { type ChatV2RuntimeDependencies = { startServer?: (options: HeddleControlPlaneServerOptions) => Promise; + createLogger?: (stateRoot: string) => HeddleControlPlaneServerOptions['logger']; }; export async function runChatCliV2Command(options: ChatCliV2CommandOptions): Promise { @@ -80,14 +81,19 @@ export async function resolveChatV2Runtime( } const startServer = dependencies.startServer ?? startHeddleControlPlaneServer; + const stateRoot = resolve(input.workspaceRoot, input.stateDir); + const logger = dependencies.createLogger?.(stateRoot) ?? createServerLogger({ + stateRoot, + console: false, + }); const handle = await startServer({ mode: 'embedded-chat', workspaceRoot: input.workspaceRoot, - stateRoot: resolve(input.workspaceRoot, input.stateDir), + stateRoot, preferApiKey: input.preferApiKey, host: DEFAULT_CONTROL_PLANE_HOST, - port: EMBEDDED_CONTROL_PLANE_PORT, - serveAssets: false, + port: DEFAULT_CONTROL_PLANE_PORT, + logger, }); return { @@ -111,6 +117,7 @@ export function formatChatV2RuntimeNotice(runtime: ChatV2Runtime): string { return [ 'Heddle notice: started embedded chat-v2 control-plane server.', `server=http://${runtime.endpoint.host}:${runtime.endpoint.port}`, + `browser=http://${runtime.endpoint.host}:${runtime.endpoint.port}`, `serverId=${runtime.serverId}`, ].join(' '); } diff --git a/src/cli/main.ts b/src/cli/main.ts index 99dddcc0..8ca5114e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -71,11 +71,20 @@ async function main() { program .command('chat') - .description('start the interactive chat UI') + .description('start the API-backed interactive chat UI') .action(async () => { const resolved = resolveCliOptions(program.opts()); chdir(resolved.workspaceRoot); - writeRuntimeHostNotice('chat', resolved.runtimeHost); + await runChatCliV2Command(resolved); + }); + + program + .command('chat-v1') + .description('start the legacy interactive chat UI') + .action(async () => { + const resolved = resolveCliOptions(program.opts()); + chdir(resolved.workspaceRoot); + writeRuntimeHostNotice('chat-v1', resolved.runtimeHost); startChatCli({ ...resolved, runtimeHost: resolved.forceOwnerConflict ? undefined : resolved.runtimeHost, @@ -84,7 +93,7 @@ async function main() { program .command('chat-v2') - .description('start the API-backed interactive chat UI rewrite') + .description('start the API-backed interactive chat UI') .action(async () => { const resolved = resolveCliOptions(program.opts()); chdir(resolved.workspaceRoot); @@ -277,11 +286,7 @@ async function main() { .action(async () => { const resolved = resolveCliOptions(program.opts()); chdir(resolved.workspaceRoot); - writeRuntimeHostNotice('chat', resolved.runtimeHost); - startChatCli({ - ...resolved, - runtimeHost: resolved.forceOwnerConflict ? undefined : resolved.runtimeHost, - }); + await runChatCliV2Command(resolved); }); const argv = process.argv.slice(2); @@ -306,7 +311,7 @@ async function main() { } function isKnownCommand(command: string): boolean { - return ['chat', 'chat-v2', 'ask', 'init', 'memory', 'auth', 'eval', 'heartbeat', 'daemon', 'help'].includes(command); + return ['chat', 'chat-v1', 'chat-v2', 'ask', 'init', 'memory', 'auth', 'eval', 'heartbeat', 'daemon', 'help'].includes(command); } async function runMemoryCli( diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index a830c796..e7c03edb 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { dirname, resolve } from 'node:path'; @@ -10,7 +9,7 @@ import { controlPlaneHeartbeatEventsController } from './controllers/trpc/contro import { HeddleHeartbeatSchedulerHost } from './heartbeat-scheduler-host.js'; import { createServerLogger } from './logging/server-logger.js'; import { getWorkspaceOperationLogger } from './logging/workspace-operation-logger.js'; -import { assertWebAssetsBuilt } from './static.js'; +import { assertWebAssetsBuilt, isWebAssetsBuilt } from './static.js'; import type { HeddleControlPlaneServerHandle, HeddleControlPlaneServerOptions } from './types.js'; import type { HeartbeatSchedulerEvent } from '@/core/heartbeat/index.js'; import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js'; @@ -242,21 +241,24 @@ function logHeartbeatSchedulerEvent( logger.info({ workspaceId: workspace.id, stateRoot: workspace.stateRoot, event }, messages[event.type]); } -function resolveDefaultAssetsDir(): string { - if (process.env.HEDDLE_WEB_DIST) { - return resolve(process.env.HEDDLE_WEB_DIST); +export function resolveDefaultAssetsDir(input: { + moduleDir?: string; + env?: Pick; +} = {}): string { + const webDistOverride = input.env?.HEDDLE_WEB_DIST ?? process.env.HEDDLE_WEB_DIST; + if (webDistOverride) { + return resolve(webDistOverride); } - const moduleDir = dirname(fileURLToPath(import.meta.url)); + const moduleDir = input.moduleDir ?? dirname(fileURLToPath(import.meta.url)); const candidates = [ + resolve(moduleDir, '../../dist/src/web-v2'), resolve(moduleDir, '../web-v2'), resolve(moduleDir, '../../web-v2'), - resolve(moduleDir, '../../../src/web-v2'), ]; for (const candidate of candidates) { - const indexPath = resolve(candidate, 'index.html'); - if (existsSync(indexPath)) { + if (isWebAssetsBuilt(candidate)) { return candidate; } } diff --git a/src/server/static.ts b/src/server/static.ts index 1275290a..67915345 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -2,11 +2,15 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import express from 'express'; +export function isWebAssetsBuilt(assetsDir: string): boolean { + const resolvedAssetsDir = resolve(assetsDir); + return existsSync(join(resolvedAssetsDir, 'index.html')) && existsSync(join(resolvedAssetsDir, 'assets')); +} + export function assertWebAssetsBuilt(assetsDir: string) { const resolvedAssetsDir = resolve(assetsDir); - const indexPath = join(resolvedAssetsDir, 'index.html'); - if (!existsSync(indexPath)) { - throw new Error(`Web assets not found at ${resolvedAssetsDir}. Run yarn build before starting heddle daemon.`); + if (!isWebAssetsBuilt(resolvedAssetsDir)) { + throw new Error(`Built web assets not found at ${resolvedAssetsDir}. Run yarn build before starting heddle daemon.`); } } diff --git a/src/web-v2/index.html b/src/web-v2/index.html index 9daecadf..1521f863 100644 --- a/src/web-v2/index.html +++ b/src/web-v2/index.html @@ -3,9 +3,17 @@ + + - + + + + + + + Heddle Control Plane V2 diff --git a/src/web-v2/layout/AppFrameShared.tsx b/src/web-v2/layout/AppFrameShared.tsx index e4a18c63..6b53a49e 100644 --- a/src/web-v2/layout/AppFrameShared.tsx +++ b/src/web-v2/layout/AppFrameShared.tsx @@ -173,7 +173,7 @@ function FrameToggleButton({ size="icon" aria-expanded={!collapsed} aria-label={label} - className={`v2-icon-button absolute top-2 z-20 size-7 ${className}`} + className={`v2-frame-toggle-button v2-icon-button absolute top-2 z-20 size-7 ${className}`} onClick={onClick} >