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
36 changes: 36 additions & 0 deletions apps/ade-cli/scripts/verify-built-cli.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
Expand All @@ -12,6 +13,7 @@ const bundledRuntimeEntryPaths = [
path.join(packageRoot, "dist", "bootstrap.cjs"),
path.join(packageRoot, "dist", "adeRpcServer.cjs"),
];
const tuiPath = path.join(packageRoot, "dist", "tuiClient", "cli.mjs");
const packageJsonPath = path.join(packageRoot, "package.json");

async function runHelp(command, args) {
Expand All @@ -35,6 +37,39 @@ async function assertVersion(command, args, expectedVersion) {
}
}

async function assertIsolatedTuiHelp() {
const tuiContents = await fs.readFile(tuiPath, "utf8");
for (const token of ["__dirname", "__filename"]) {
if (tuiContents.includes(token) && !tuiContents.includes(`const ${token} =`)) {
throw new Error(`[ade-cli:build] dist/tuiClient/cli.mjs references ${token} without an ESM shim`);
}
}

const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ade-cli-tui-isolated-"));
try {
const isolatedTuiPath = path.join(tempDir, "cli.mjs");
const runnerPath = path.join(tempDir, "run-tui-help.mjs");
await fs.copyFile(tuiPath, isolatedTuiPath);
await fs.writeFile(
runnerPath,
"const tui = await import('./cli.mjs');\nprocess.exitCode = await tui.runAdeCodeCli(['--help']);\n",
"utf8",
);
const { stdout } = await execFileAsync(process.execPath, [runnerPath], {
cwd: tempDir,
env: {
...process.env,
NODE_PATH: "",
},
});
if (!stdout.includes("Terminal-native ADE Work chat.")) {
throw new Error("[ade-cli:build] isolated TUI help output did not include the ADE code banner text");
}
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}

const contents = await fs.readFile(cliPath, "utf8");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const expectedVersion = process.env.ADE_CLI_VERSION?.trim() || packageJson.version;
Expand Down Expand Up @@ -68,6 +103,7 @@ if (process.platform !== "win32" && (stat.mode & 0o111) === 0) {

await runHelp(process.execPath, [cliPath, "--help"]);
await assertVersion(process.execPath, [cliPath, "--version"], expectedVersion);
await assertIsolatedTuiHelp();

if (process.platform !== "win32") {
await runHelp(cliPath, ["--help"]);
Expand Down
42 changes: 36 additions & 6 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,9 +1451,16 @@ function resolveLaneWorktreePathForSync(args: SyncRemoteCommandServiceArgs, lane
return null;
}

async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) {
async function resolveLaneOverlayContext(
args: SyncRemoteCommandServiceArgs,
laneId: string,
options: { includeArchived?: boolean } = {},
) {
const projectConfigService = requireService(args.projectConfigService, "Project config service not available.");
const lanes = await args.laneService.list({ includeStatus: false });
const lanes = await args.laneService.list({
includeStatus: false,
...(options.includeArchived === true ? { includeArchived: true } : {}),
});
const lane = lanes.find((entry) => entry.id === laneId);
if (!lane) throw new Error(`Lane not found: ${laneId}`);

Expand All @@ -1470,6 +1477,31 @@ async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, lan
};
}

async function deleteLaneWithRuntimeCleanup(
args: SyncRemoteCommandServiceArgs,
payload: Record<string, unknown>,
): Promise<{ ok: true }> {
const deleteArgs = parseDeleteLaneArgs(payload);
const envContext = args.laneEnvironmentService
? await resolveLaneOverlayContext(args, deleteArgs.laneId, { includeArchived: true }).catch((error: unknown) => {
args.logger.warn("sync_remote.lane_env_cleanup.pre_delete_context_failed", {
laneId: deleteArgs.laneId,
err: String(error),
});
return null;
})
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const teardownEnv = args.laneEnvironmentService && envContext?.envInitConfig
? async () => {
await args.laneEnvironmentService!.cleanupLaneEnvironment(envContext.lane, envContext.envInitConfig);
}
: undefined;

await args.laneService.delete(deleteArgs, { teardownEnv });
args.portAllocationService?.release(deleteArgs.laneId);
return { ok: true };
}

async function resolveChatCreateArgs(
service: ReturnType<typeof createAgentChatService>,
payload: AgentChatCreateArgs,
Expand Down Expand Up @@ -1687,10 +1719,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive"));
return { ok: true };
});
register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => {
await args.laneService.delete(parseDeleteLaneArgs(payload));
return { ok: true };
});
register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) =>
deleteLaneWithRuntimeCleanup(args, payload));
register("lanes.getStackChain", { viewerAllowed: true }, async (payload) =>
args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId.")));
register("lanes.getChildren", { viewerAllowed: true }, async (payload) =>
Expand Down
34 changes: 34 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,40 @@ describe("FooterControls", () => {
expect(frame).toContain("help");
});

it("adds the Claude terminal control toggle when a Claude terminal is active", () => {
const result = render(
<FooterControls
provider="claude"
modelDisplay="claude-opus"
permissionLabel="default"
terminalControlAvailable
/>,
);
const frame = stripAnsi(result.lastFrame() ?? "");

expect(frame).toContain("^t");
expect(frame).toContain("Claude");
});

it("renders dedicated Claude control hints while the terminal owns input", () => {
const result = render(
<FooterControls
provider="claude"
modelDisplay="claude-opus"
permissionLabel="default"
terminalControlAvailable
terminalControlActive
/>,
);
const frame = stripAnsi(result.lastFrame() ?? "");

expect(frame).toContain("CLAUDE CONTROL");
expect(frame).toContain("^t");
expect(frame).toContain("ADE");
expect(frame).toContain("^]");
expect(frame).not.toContain("^o lanes");
});

it("renders a focus indicator and cell hints when the inline row is focused", () => {
const result = render(
<FooterControls
Expand Down
20 changes: 20 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ describe("TerminalPane", () => {
expect(rows[0]?.runs[0]?.style.bold).toBe(true);
});

it("marks direct Claude terminal control with the escape hints", () => {
const result = render(
<TerminalPane
title="Claude Code"
preview={preview([row("permission prompt"), row("1. Yes")])}
liveChunks={[]}
attached
width={80}
height={5}
hiddenBottomRows={2}
/>,
);
const frame = stripAnsi(result.lastFrame() ?? "");

expect(frame).toContain("CLAUDE CONTROL");
expect(frame).toContain("Ctrl+T returns to ADE");
expect(frame).toContain("Ctrl+] escape");
expect(frame).toContain("permission prompt");
});

it("uses transcript history for closed terminal sessions instead of the final resume-only snapshot", async () => {
const result = render(
<TerminalPane
Expand Down
22 changes: 22 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
footerControlsForAvailability,
isPromptLineBackspace,
isPromptWordBackspace,
isTerminalControlToggle,
isTerminalMouseTrackingEnabled,
parseTerminalMouseInput,
promptDisplayRows,
resolveChatWrapWidth,
resolveTerminalPaneWidth,
splitTerminalControlInput,
subagentSnapshotsFromEvents,
} from "../app";
import { clampTerminalPaneCols } from "../components/TerminalPane";
Expand Down Expand Up @@ -98,6 +100,26 @@ describe("footer control ordering", () => {
});
});

describe("terminal control toggle", () => {
it("recognizes ctrl-t from Ink key data and raw terminal bytes", () => {
expect(isTerminalControlToggle("t", { ctrl: true })).toBe(true);
expect(isTerminalControlToggle("T", { ctrl: true })).toBe(true);
expect(isTerminalControlToggle("\x14", {})).toBe(true);
expect(isTerminalControlToggle("t", {})).toBe(false);
});

it("detaches from terminal control while preserving other raw input bytes", () => {
expect(splitTerminalControlInput("a\x14b\x1dc")).toEqual({
detach: true,
forwarded: "abc",
});
expect(splitTerminalControlInput("\x1b[A")).toEqual({
detach: false,
forwarded: "\x1b[A",
});
});
});

describe("pane width helpers", () => {
it("caps prose chat width but lets embedded terminals use the full center pane", () => {
expect(resolveChatWrapWidth(180, false, 0)).toBe(110);
Expand Down
51 changes: 42 additions & 9 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,15 @@ export function encodeTerminalPromptSubmit(value: string): string {
return `${normalized}\r`;
}

export function isTerminalControlToggle(input: string, key: { ctrl?: boolean }): boolean {
return input === "\x14" || (key.ctrl === true && input.toLowerCase() === "t");
}

export function splitTerminalControlInput(raw: string): { detach: boolean; forwarded: string } {
const forwarded = raw.replace(/[\x14\x1d]/g, "");
return { detach: forwarded.length !== raw.length, forwarded };
}

function claudeTerminalRowsForPane(rows: number): number {
const safeRows = finiteFloor(rows, 4);
return Math.max(
Expand Down Expand Up @@ -1956,6 +1965,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
() => terminalSessions.find((session) => session.terminalId === activeSessionId) ?? null,
[activeSessionId, terminalSessions],
);
const activeTerminalProvider = terminalSessionProvider(activeTerminalSession);
const displaySessions = useMemo(
() => [...sessions, ...terminalSessions.map(terminalSessionToChatSummary)]
.sort((left, right) => {
Expand All @@ -1965,7 +1975,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
}),
[sessions, terminalSessions],
);
const activeCommandProvider = terminalSessionProvider(activeTerminalSession) ?? activeSession?.provider ?? modelState.provider;
const claudeTerminalControlAvailable = Boolean(
activeTerminalSession
&& activeTerminalSession.status === "running"
&& activeTerminalProvider === "claude",
);
const claudeTerminalControlActive = claudeTerminalControlAvailable
&& attachedTerminalId === activeTerminalSession?.terminalId;
const activeCommandProvider = activeTerminalProvider ?? activeSession?.provider ?? modelState.provider;
// Once a chat has any sent user message, the provider is locked — swapping
// mid-thread breaks runtime continuity. Derived from events; persists across reloads.
const providerLocked = useMemo(() => Boolean(activeSession) && hasFirstUserMessage(events), [activeSession, events]);
Expand Down Expand Up @@ -2286,10 +2303,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }

useEffect(() => {
if (!connection || !activeTerminalSession) return;
const cols = clampTerminalPaneCols(terminalPaneWidth);
const terminalRows = claudeTerminalRowsForPane(chatRowBudget);
const cols = clampTerminalPaneCols(claudeTerminalControlActive ? terminalPaneWidth - 2 : terminalPaneWidth);
const terminalRows = claudeTerminalControlActive
? Math.max(4, chatRowBudget - 1)
: claudeTerminalRowsForPane(chatRowBudget);
void resizeTerminal(connection, activeTerminalSession.terminalId, cols, terminalRows).catch(() => {});
}, [activeTerminalSession, chatRowBudget, connection, terminalPaneWidth]);
}, [activeTerminalSession, chatRowBudget, claudeTerminalControlActive, connection, terminalPaneWidth]);

useEffect(() => {
if (!connection || !activeTerminalSession) return;
Expand Down Expand Up @@ -2714,11 +2733,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
const handleRawInput = (chunk: Buffer | string) => {
const raw = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
if (!raw) return;
if (raw.includes("\x1d")) {
const forwarded = raw.replace(/\x1d/g, "");
const terminalControlInput = splitTerminalControlInput(raw);
if (terminalControlInput.detach) {
setAttachedTerminalId(null);
if (forwarded) {
void writeTerminal(connection, attachedTerminalId, forwarded).catch((err) => {
if (terminalControlInput.forwarded) {
void writeTerminal(connection, attachedTerminalId, terminalControlInput.forwarded).catch((err) => {
addNotice(err instanceof Error ? err.message : String(err), "error");
});
}
Expand Down Expand Up @@ -5623,7 +5642,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }

useInput((input, key) => {
if (attachedTerminalIdRef.current) {
if (input === "\x1d") setAttachedTerminalId(null);
if (input === "\x1d" || isTerminalControlToggle(input, key)) setAttachedTerminalId(null);
return;
}
if (isTerminalControlToggle(input, key)) {
const terminal = activeTerminalSession ?? activeTerminalSessionRef.current;
if (
terminal?.terminalId === activeSessionIdRef.current
&& terminal.status === "running"
&& terminalSessionProvider(terminal) === "claude"
) {
focusChat();
setAttachedTerminalId(terminal.terminalId);
}
return;
}
const mouse = parseTerminalMouseInput(input);
Expand Down Expand Up @@ -6715,6 +6746,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
providerLocked={providerLocked}
subagentsButtonVisible={subagentsButtonVisible}
planMode={isPlanMode(modelState)}
terminalControlAvailable={claudeTerminalControlAvailable}
terminalControlActive={claudeTerminalControlActive}
/>
</Box>
</SpinTickProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/tuiClient/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Usage:
Keys:
ctrl-o open or focus lanes and chats
ctrl-p open or focus details
ctrl-t toggle Claude terminal control when a Claude Code terminal is active
shift-tab cycle pane focus
esc return or cancel the active pane
? help when it is the first and only prompt character
Expand Down
20 changes: 19 additions & 1 deletion apps/ade-cli/src/tuiClient/components/FooterControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export function FooterControls({
inlineRowFocused,
inlineRowCell,
planMode,
terminalControlAvailable,
terminalControlActive,
}: {
provider?: AdeCodeProvider | null;
providerLocked?: boolean;
Expand All @@ -107,6 +109,8 @@ export function FooterControls({
inlineRowFocused?: boolean;
inlineRowCell?: InlineRowCell;
planMode?: boolean;
terminalControlAvailable?: boolean;
terminalControlActive?: boolean;
}) {
const brand = provider ? theme.provider(provider) : null;
const rowFocused = inlineRowFocused === true;
Expand Down Expand Up @@ -211,7 +215,15 @@ export function FooterControls({
) : null}
</Text>
<Text wrap="truncate-start">
{approvalActive ? (
{terminalControlActive ? (
<>
<Text color={theme.color.warning} bold>CLAUDE CONTROL</Text>
<Text dimColor>{" · "}</Text>
<Hint keyLabel="^t" action="ADE" />
<Text dimColor>{" "}</Text>
<Hint keyLabel="^]" action="escape" />
</>
) : approvalActive ? (
<>
<Text color={theme.color.accent} bold>a</Text>
<Text dimColor>{" approve "}</Text>
Expand Down Expand Up @@ -239,6 +251,12 @@ export function FooterControls({
<Hint keyLabel="/" action="cmds" />
<Text dimColor>{" "}</Text>
<Hint keyLabel="?" action="help" />
{terminalControlAvailable ? (
<>
<Text dimColor>{" "}</Text>
<Hint keyLabel="^t" action="Claude" />
</>
) : null}
</>
)}
</Text>
Expand Down
Loading
Loading