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
316 changes: 315 additions & 1 deletion .claude/commands/automate.md

Large diffs are not rendered by default.

271 changes: 11 additions & 260 deletions .claude/commands/finalize.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,41 @@ function createRuntime() {
list: vi.fn(() => [{ id: "op-1", kind: "git_push", status: "running" }]),
},
projectConfigService: {} as any,
aiIntegrationService: {
getStatus: vi.fn(async () => ({
mode: "subscription",
availableProviders: {
claude: true,
codex: true,
cursor: false,
droid: false,
},
models: {
claude: [],
codex: [],
cursor: [],
droid: [],
},
detectedAuth: [
{ type: "cli-subscription", cli: "codex", authenticated: true },
],
providerConnections: {},
runtimeConnections: {},
availableModelIds: ["openai/gpt-5.5"],
opencodeBinaryInstalled: true,
opencodeBinarySource: "bundled",
opencodeInventoryError: null,
opencodeProviders: [],
apiKeyStore: {
secureStorageAvailable: true,
legacyPlaintextDetected: false,
decryptionFailed: false,
},
})),
getDailyUsageBatch: vi.fn(() => new Map()),
getFeatureFlag: vi.fn(() => true),
getDailyBudgetLimit: vi.fn(() => null),
} as any,
conflictService: {
runPrediction: vi.fn(async () => ({ lanes: [], matrix: [], overlaps: [] })),
getLaneStatus: vi.fn(async ({ laneId }: { laneId: string }) => ({ laneId, status: "merge-ready" })),
Expand Down Expand Up @@ -4113,6 +4148,7 @@ describe("adeRpcServer", () => {
const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" });
expect(allDomains?.isError).toBeUndefined();
expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(true);
expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "ai")).toBe(true);
expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "mission")).toBe(true);
expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(true);
expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(true);
Expand Down Expand Up @@ -4187,6 +4223,18 @@ describe("adeRpcServer", () => {
expect(keybindings?.isError).toBeUndefined();
expect(fixture.runtime.keybindingsService.get).toHaveBeenCalled();

const aiStatus = await callTool(handler, "run_ade_action", {
domain: "ai",
action: "getStatus",
args: { refreshOpenCodeInventory: true },
});
expect(aiStatus?.isError).toBeUndefined();
expect(fixture.runtime.aiIntegrationService.getStatus).toHaveBeenCalledWith({
force: false,
refreshOpenCodeInventory: true,
});
expect(aiStatus.structuredContent.result.availableModelIds).toContain("openai/gpt-5.5");

const layoutSet = await callTool(handler, "run_ade_action", {
domain: "layout",
action: "set",
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ export async function createAdeRuntime(args: {
swallow(() => linearOAuthService.dispose());
swallow(() => headlessLinearServices.dispose());
swallow(() => aiOrchestratorService.dispose());
swallow(() => agentChatService?.forceDisposeAll?.());
swallow(() => testService.disposeAll());
swallow(() => ptyService.disposeAll());
swallow(() => db.flushNow());
Expand Down
9 changes: 9 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ describe("ADE CLI", () => {
expect(plan).toEqual({ kind: "ade-code", rest: ["--print-state"] });
});

it("shows socket-aware TUI help for ade code --help", () => {
const plan = buildCliPlan(["code", "--help"]);
expect(plan.kind).toBe("help");
if (plan.kind !== "help") return;
expect(plan.text).toContain("ade code --socket /tmp/ade.sock");
expect(plan.text).toContain("ade code --require-socket");
expect(plan.text).toContain("Command palette");
});

it("shows help for bare ade invocations", () => {
expect(buildCliPlan([])).toEqual({
kind: "help",
Expand Down
17 changes: 14 additions & 3 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,14 +883,25 @@ const HELP_BY_COMMAND: Record<string, string> = {
code: `${ADE_BANNER}
ADE Code

Launch the terminal-native ADE Work chat. It shares lanes, chat sessions,
transcript state, and slash commands with desktop ADE.
Launch the terminal-native ADE Work chat. It uses the same project lanes,
chat sessions, transcript state, and slash commands as desktop ADE, but it
does not require the desktop app to be running.

$ ade code Start the TUI for the current project
$ ade code --print-state Smoke-test attach/embed state
$ ade code --embedded Force the embedded runtime fallback
$ ade code --require-socket Fail instead of embedding when no socket exists
$ ade code --socket /tmp/ade.sock Attach to a specific runtime socket
$ ade --project-root <path> code Launch against a specific ADE project
`,

Keys:
ctrl-o Open or focus lanes and chats
ctrl-p Open or focus details
shift-tab Cycle pane focus
esc Return or cancel the active pane
? Help when it is the first prompt character
/ Command palette
`,
lanes: `${ADE_BANNER}
Lanes

Expand Down
22 changes: 17 additions & 5 deletions apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,16 +755,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
return [];
}
};
const commandLedgerScopeKey = (): string =>
toOptionalString(args.projectId) ?? args.projectRoot;
const commandLedgerKeyPrefix = (): string => `${commandLedgerScopeKey()}:`;
const commandLedgerLegacyRootPrefix = (): string => `${args.projectRoot}:`;
const writePersistedCommandLedger = (): void => {
const nowMs = Date.now();
const commands: PersistedMobileCommand[] = [];
const prefix = commandLedgerKeyPrefix();
for (const [key, record] of mobileCommandResultCache) {
if (!record.result || record.completedAtMs == null) continue;
const persistedResult = persistedMobileCommandResult(record.action, record.result);
if (!persistedResult) continue;
if (!key.startsWith(`${args.projectRoot}:`)) continue;
if (!key.startsWith(prefix)) continue;
if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue;
const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? "";
const deviceId = key.slice(prefix.length).split(":")[0] ?? "";
commands.push({
key,
projectRoot: args.projectRoot,
Expand Down Expand Up @@ -795,7 +800,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
? mobileCommandArgsFingerprint(legacyArgsKey)
: null;
if (!argsFingerprint) continue;
mobileCommandResultCache.set(command.key, {
const key =
command.key.startsWith(commandLedgerLegacyRootPrefix()) &&
commandLedgerScopeKey() !== args.projectRoot
? `${commandLedgerKeyPrefix()}${command.key.slice(commandLedgerLegacyRootPrefix().length)}`
: command.key;
mobileCommandResultCache.set(key, {
commandId: command.commandId,
action: command.action,
argsKey: argsFingerprint,
Expand All @@ -809,10 +819,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
}
};
const commandLedgerSizeForProject = (): number =>
[...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length;
[...mobileCommandResultCache.keys()].filter((key) =>
key.startsWith(commandLedgerKeyPrefix()),
).length;
const dropInFlightCommandRecordsForProject = (): void => {
for (const [key, record] of mobileCommandResultCache) {
if (!key.startsWith(`${args.projectRoot}:`)) continue;
if (!key.startsWith(commandLedgerKeyPrefix())) continue;
if (record.result == null) mobileCommandResultCache.delete(key);
}
};
Expand Down
162 changes: 162 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { render } from "ink-testing-library";
import { ChatView } from "../components/ChatView";
import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat";

const session: AgentChatSessionSummary = {
sessionId: "s1",
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
status: "idle",
startedAt: "2026-01-01T12:00:00.000Z",
endedAt: null,
lastActivityAt: "2026-01-01T12:00:00.000Z",
lastOutputPreview: null,
summary: null,
};

function renderEvents(
events: AgentChatEventEnvelope[],
options: { maxRows?: number; scrollOffsetRows?: number; width?: number } = {},
): string {
const result = render(
<ChatView
events={events}
notices={[]}
activeSession={session}
projectName="ADE"
laneName="Primary"
maxRows={options.maxRows}
scrollOffsetRows={options.scrollOffsetRows}
width={options.width}
/>,
);
return result.lastFrame() ?? "";
}

describe("ChatView", () => {
it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => {
const frame = renderEvents([]);
expect(frame).toMatch(/[╭╮╯╰]/);
expect(frame).toContain("██████");
expect(frame).toContain("ade code");
expect(frame).toContain("v0.1");
expect(frame).toContain("Project");
expect(frame).toContain("Lane");
expect(frame).toContain("Branch");
expect(frame).toContain("Primary");
expect(frame).toContain("type to chat");
expect(frame).toContain("commands");
});

it("right-aligns user messages inside an accent-bordered bubble", () => {
const frame = renderEvents([
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: { type: "user_message", text: "hello" },
},
]);
const lines = frame.split(/\r?\n/);
const bubbleLine = lines.find((line) => line.includes("hello"));
expect(bubbleLine, "expected the rendered frame to include the user message").toBeDefined();
// Round border characters wrap the bubble; verify presence so layout stays a bubble.
expect(frame).toMatch(/[╭╮╯╰]/);
// Bubble is right-aligned: the content sits past the half-width of the frame.
const helloIndex = (bubbleLine ?? "").indexOf("hello");
expect(helloIndex).toBeGreaterThan(0);
});

it("renders assistant messages flat without the bubble border", () => {
const frame = renderEvents([
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: { type: "text", text: "I'm Codex." },
},
]);
expect(frame).toContain("I'm Codex.");
// No round-border glyphs in an assistant-only frame.
expect(frame).not.toMatch(/[╭╮╯╰]/);
});

it("renders markdown-like assistant output into readable blocks", () => {
const frame = renderEvents([
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: {
type: "text",
text: [
"## Fix plan",
"",
"- Trace commands",
"1. Patch renderer",
"",
"```ts",
"const ok = true;",
"```",
].join("\n"),
},
},
], { width: 60 });
expect(frame).toContain("Fix plan");
expect(frame).toContain("• Trace commands");
expect(frame).toContain("1. Patch renderer");
expect(frame).toContain("│ const ok = true;");
});

it("wraps long assistant paragraphs to the supplied width", () => {
const frame = renderEvents([
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: { type: "text", text: "This paragraph should wrap cleanly across more than one terminal row instead of flattening into an unreadable single line." },
},
], { width: 42 });
expect(frame).toContain("This paragraph should wrap cleanly");
expect(frame).toContain("across more than one terminal row");
});

it("shows the bottom viewport by default and older rows when scrolled", () => {
const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({
sessionId: "s1",
timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`,
sequence: index + 1,
event: index % 2 === 0
? { type: "user_message", text: `user row ${index + 1}` }
: { type: "text", text: `assistant row ${index + 1}` },
}));
const bottom = renderEvents(events, { maxRows: 5, width: 80 });
expect(bottom).toContain("assistant row 12");
expect(bottom).not.toContain("user row 1");
expect(bottom).toContain("↑ older messages");

const older = renderEvents(events, { maxRows: 5, scrollOffsetRows: 8, width: 80 });
expect(older).toContain("row");
expect(older).toContain("↓ newer messages");
expect(older).not.toContain("assistant row 12");
});

it("indents tool call output", () => {
const frame = renderEvents([
{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 },
},
]);
const lines = frame.split(/\r?\n/).filter((line) => line.includes("run git branch"));
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line.startsWith(" ")).toBe(true);
}
});
});
Loading