Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-tui-status-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

The local `eve dev` status bar now shows a gray `:port` badge and retains it as terminal width narrows. Status segments now use tighter spacing.
5 changes: 5 additions & 0 deletions .changeset/reconnect-dev-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Running `eve dev` interactively now reconnects to the healthy loopback dev server recorded for the same app root, with a fresh session for each attached terminal UI. Eve replaces stale or malformed state when it starts a new server. `--host`, `--port`, or `PORT` skips reconnection and reports a healthy recorded server instead.
2 changes: 1 addition & 1 deletion docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The conversation streams straight into your terminal's normal scrollback, so you

Each turn renders without boxes. A colored gutter glyph marks who is speaking, tool calls collapse to a one-line summary (`✓ get_weather city="SF" → 73°F`), and a subagent's work is indented beneath its `◆` header. When input is ready, the prompt stays bare until you type. A green circle-dot pulses while the agent is waiting to answer and disappears when reasoning or answer content begins.

A persistent line beneath the prompt or status shows the model, the session's token flow (`↑ 394.4K ↓ 4.3K`), the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Remote status lines omit AI Gateway endpoint state.
A persistent line beneath the prompt or status shows the model, the session's token flow (`↑ 394.4K ↓ 4.3K`), the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Other status segments use `·` separators. Remote status lines omit AI Gateway endpoint state.

Errors render compactly with docs links highlighted. A code bug escaping your agent's own code shows its stack trace dim beneath the error headline. Dev-server rebuilds condense into one status row that updates in place (`tui/setup-panel.ts changed · rebuilding…`, then `· rebuilt`); only the latest rebuild shows, and paths shrink to their last two components.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Pass a bare URL as the only argument and the UI connects to that server instead
| `--context-size <tokens>` | number | none | Model context window size, shown as a usage percentage |
| `--logs <mode>` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` |

Local dev writes the active server process ID to `.eve/dev-process.pid`. If another `eve dev` starts for the same agent while that process is still running, eve exits with a message that includes the command to stop the existing server.
Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead.

Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`.

Expand Down
65 changes: 64 additions & 1 deletion packages/eve/src/cli/dev/tui/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "./runner.js";
import { createPromptCommandHandler } from "./prompt-command-handler.js";
import { promptCommandsFor } from "./prompt-commands.js";
import { interruptedError } from "./errors.js";
import type { RemoteAuthFlow } from "./remote-auth.js";
import type { RemoteAuthCompletedMutation } from "./remote-auth-result.js";
import type { RemoteConnectionControllerOptions } from "./remote-connection.js";
Expand Down Expand Up @@ -1504,6 +1505,66 @@ describe("EveTUIRunner renderer teardown", () => {

expect(shutdown).toHaveBeenCalledTimes(1);
});

it("aborts child-session streams when Ctrl-C exits the runner", async () => {
const client = stubClient();
const childSession = client.session({ sessionId: "child-session", streamIndex: 0 });
let childSignal: AbortSignal | undefined;
vi.spyOn(client, "session").mockReturnValue(childSession);
vi.spyOn(childSession, "stream").mockImplementation((options) => {
const signal = options?.signal;
if (signal === undefined) {
throw new Error("Expected the child stream to receive an abort signal.");
}
childSignal = signal;
return {
async *[Symbol.asyncIterator]() {
await new Promise<void>((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
yield {
type: "session.waiting",
data: { wait: "next-user-message" },
} as HandleMessageStreamEvent;
},
};
});

const runner = new EveTUIRunner({
client,
name: "Weather Agent",
renderer: fakeRenderer({
readPrompt: vi
.fn()
.mockResolvedValueOnce("delegate")
.mockRejectedValueOnce(interruptedError()),
renderStream: vi.fn(async (result) => {
for await (const event of result.events as AsyncIterable<unknown>) void event;
}),
}),
session: sessionYielding([
{
type: "subagent.called",
data: {
callId: "call-child",
childSessionId: "child-session",
name: "weather-child",
sequence: 0,
sessionId: "parent-session",
toolName: "delegate_weather",
turnId: "turn-parent",
workflowId: "workflow-parent",
},
},
{ type: "turn.completed", data: { sequence: 0, turnId: "turn-parent" } },
{ type: "session.waiting", data: { wait: "next-user-message" } },
]),
});

await runner.run();

expect(childSignal?.aborted).toBe(true);
});
});

describe("EveTUIRunner Vercel status line", () => {
Expand Down Expand Up @@ -1536,7 +1597,9 @@ describe("EveTUIRunner Vercel status line", () => {
await runner.run();

expect(pushes).toEqual([{ identity, pendingDeploy: false }]);
expect(detectIdentity).toHaveBeenCalledWith("/tmp/weather-agent");
expect(detectIdentity).toHaveBeenCalledWith("/tmp/weather-agent", {
signal: expect.any(AbortSignal),
});
});

it("applies command effects: channels mark pending, deploy clears and re-probes", async () => {
Expand Down
17 changes: 11 additions & 6 deletions packages/eve/src/cli/dev/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ export class EveTUIRunner {
readonly #subagentRuns = new Map<string, SubagentRun>();
/**
* callId → AbortController for the parallel child-session stream pump
* launched on `subagent.called`. Cancelled on `subagent.completed` or
* when the runner shuts down.
* launched on `subagent.called`. Cancelled on `subagent.completed`, when
* the session resets, or when the runner shuts down.
*/
readonly #subagentChildPumps = new Map<string, AbortController>();
/**
Expand Down Expand Up @@ -587,6 +587,7 @@ export class EveTUIRunner {
} finally {
this.#disposed = true;
this.#authProbeAbort.abort();
this.#abortSubagentChildPumps();
// Restore captured stdout/stderr before a fatal error reaches the CLI.
this.#unsubscribeDevelopmentSandboxLogs?.();
this.#unsubscribeDevelopmentSandboxLogs = undefined;
Expand Down Expand Up @@ -819,10 +820,7 @@ export class EveTUIRunner {
* In-flight subagent child-session streams are aborted.
*/
#startNewSession(): void {
for (const controller of this.#subagentChildPumps.values()) {
controller.abort();
}
this.#subagentChildPumps.clear();
this.#abortSubagentChildPumps();
this.#subagentRuns.clear();
this.#pendingInputRequests.clear();
this.#connectionAuthRuns.clear();
Expand All @@ -834,6 +832,13 @@ export class EveTUIRunner {
this.#runtimeArtifacts?.clear();
}

#abortSubagentChildPumps(): void {
for (const controller of this.#subagentChildPumps.values()) {
controller.abort();
}
this.#subagentChildPumps.clear();
}

async #readPromptWithIdleRefresh(options: AgentTUISessionOptions): Promise<string | undefined> {
if (!this.#renderer.readPrompt) {
return undefined;
Expand Down
45 changes: 29 additions & 16 deletions packages/eve/src/cli/dev/tui/status-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ function deployedRemote(
}

describe("buildStatusLine", () => {
it("leads local sessions with a gray colon-prefixed port badge", () => {
const input = {
serverPort: "3000",
model: "openai/gpt-5.5",
} as const;

const line = buildStatusLine({ ...input, theme, width: 120 })!;
expect(stripAnsi(line)).toBe(" :3000 openai/gpt-5.5");
expect(buildStatusLine({ ...input, theme: plain, width: " :3000 ".length })).toBe(" :3000 ");
expect(line).toContain("\x1b[7m\x1b[90m :3000 \x1b[39m\x1b[27m");
expect(line).not.toContain("\x1b[7m\x1b[34m :3000 ");
});

it("renders all segments in order with dot separators", () => {
const line = buildStatusLine({
model: "anthropic/claude-sonnet-4-6",
Expand All @@ -48,7 +61,7 @@ describe("buildStatusLine", () => {
});

expect(line).toBe(
"anthropic/claude-sonnet-4-6 · 12,300 tokens 6% · AI Gateway (my-agent) · /deploy pending",
"anthropic/claude-sonnet-4-6 · 12,300 tokens 6% · AI Gateway (my-agent) · /deploy pending",
);
});

Expand All @@ -60,7 +73,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · openai/gpt-5");
).toBe(" ↗ vpoke.playground-vercel.tools openai/gpt-5");
});

it("dims every segment except the yellow pending-deploy marker", () => {
Expand All @@ -85,7 +98,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
});
expect(withProject).toBe("m · AI Gateway (my-agent)");
expect(withProject).toBe("m · AI Gateway (my-agent)");

// Connected without a linked project (a raw key): bare "AI Gateway".
const noProject = buildStatusLine({
Expand All @@ -94,7 +107,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
});
expect(noProject).toBe("m · AI Gateway");
expect(noProject).toBe("m · AI Gateway");
});

it("renders the pending marker even when no segment else resolved", () => {
Expand All @@ -117,7 +130,7 @@ describe("buildStatusLine", () => {
} as const;

const full = buildStatusLine({ ...input, width: 120 })!;
expect(full.startsWith("logs: sandbox · ")).toBe(true);
expect(full.startsWith("logs: sandbox · ")).toBe(true);

// Narrow enough that only the leading hint survives.
expect(buildStatusLine({ ...input, width: 13 })).toBe("logs: sandbox");
Expand Down Expand Up @@ -150,7 +163,7 @@ describe("buildStatusLine", () => {
expect(noEndpoint).toContain("anthropic/claude-sonnet-4-6");

const noModel = buildStatusLine({ ...input, width: visibleLength(noEndpoint) - 1 })!;
expect(noModel).toBe("12,300 tokens · /deploy pending");
expect(noModel).toBe("12,300 tokens · /deploy pending");
});

it("renders the three model-endpoint states", () => {
Expand All @@ -160,7 +173,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
});
expect(external).toBe("anthropic/claude-sonnet-4-6 · External endpoint");
expect(external).toBe("anthropic/claude-sonnet-4-6 · External endpoint");

const linked = buildStatusLine({
model: "m",
Expand All @@ -169,15 +182,15 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
});
expect(linked).toBe("m · AI Gateway (my-agent)");
expect(linked).toBe("m · AI Gateway (my-agent)");

const notConnected = buildStatusLine({
model: "m",
endpoint: { kind: "gateway", connected: false },
theme: plain,
width: 120,
});
expect(notConnected).toBe("m · ⚠ AI Gateway");
expect(notConnected).toBe("m · ⚠ AI Gateway");
});

it("paints only the not-connected endpoint yellow", () => {
Expand All @@ -203,7 +216,7 @@ describe("buildStatusLine", () => {
theme: ascii,
width: 120,
});
expect(stripAnsi(line!)).toBe("m - ! AI Gateway");
expect(stripAnsi(line!)).toBe("m - ! AI Gateway");
});

it("renders the remote badge first and projects each authentication state", () => {
Expand All @@ -213,7 +226,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · Checking access…");
).toBe(" ↗ vpoke.playground-vercel.tools · Checking access…");
expect(
buildStatusLine({
remote: remote({
Expand All @@ -223,7 +236,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · Authenticate via OIDC");
).toBe(" ↗ vpoke.playground-vercel.tools · Authenticate via OIDC");
expect(
buildStatusLine({
remote: remote({
Expand All @@ -233,7 +246,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…");
).toBe(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…");
expect(
buildStatusLine({
remote: remote({
Expand All @@ -243,7 +256,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · Authentication failed");
).toBe(" ↗ vpoke.playground-vercel.tools · Authentication failed");
expect(
buildStatusLine({
remote: remote({
Expand All @@ -253,7 +266,7 @@ describe("buildStatusLine", () => {
theme: plain,
width: 120,
}),
).toBe(" ↗ vpoke.playground-vercel.tools · Remote unavailable");
).toBe(" ↗ vpoke.playground-vercel.tools · Remote unavailable");
expect(
buildStatusLine({
remote: deployedRemote({ state: "ready", info: {} as never }),
Expand Down Expand Up @@ -343,6 +356,6 @@ describe("buildStatusLine", () => {
theme: ascii,
width: 120,
});
expect(line).toBe(" -> vpoke.playground-vercel.tools - Checking access…");
expect(line).toBe(" -> vpoke.playground-vercel.tools - Checking access…");
});
});
Loading
Loading