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
2 changes: 2 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,8 @@ export async function createAdeRuntime(args: {
laneService,
sessionService,
processRegistry,
aiIntegrationService,
projectConfigService,
logger,
broadcastData: (event) => {
pushEvent("pty", { type: "pty_data", event });
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12069,6 +12069,38 @@ describe("createAgentChatService", () => {
});
});

describe("readTranscript", () => {
it("refuses non-chat sessions even when a transcript file exists", async () => {
const { service, sessionService } = createService();
const transcriptPath = path.join(tmpRoot, "transcripts", "terminal-session.chat.jsonl");
fs.writeFileSync(
transcriptPath,
`${JSON.stringify({
sessionId: "terminal-session",
timestamp: "2026-06-30T12:00:00.000Z",
event: { type: "user_message", text: "terminal secret" },
sequence: 1,
})}\n`,
"utf8",
);
sessionService.create({
sessionId: "terminal-session",
laneId: "lane-1",
toolType: "terminal",
transcriptPath,
});
vi.mocked(parseAgentChatTranscript).mockReturnValue([{
sessionId: "terminal-session",
timestamp: "2026-06-30T12:00:00.000Z",
event: { type: "user_message", text: "terminal secret" },
sequence: 1,
}]);

await expect(service.readTranscript("terminal-session")).resolves.toEqual([]);
expect(parseAgentChatTranscript).not.toHaveBeenCalled();
});
});

describe("getChatEventHistory", () => {
it("returns an empty history for an unknown session", async () => {
const { service } = createService();
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28003,10 +28003,14 @@ export function createAgentChatService(args: {
limit?: number,
since?: string,
): Promise<AgentChatTranscriptEntry[]> => {
const managed = managedSessions.get(sessionId);
const trimmedId = sessionId.trim();
if (!trimmedId.length) return [];
const row = sessionService.get(trimmedId);
if (!row || !isChatToolType(row.toolType)) return [];
const managed = managedSessions.get(trimmedId);
const entries = managed
? readTranscriptEntries(managed)
: transcriptEntriesFromEnvelopes(sessionId, readFullTranscriptEnvelopesForSessionId(sessionId));
: transcriptEntriesFromEnvelopes(trimmedId, readFullTranscriptEnvelopesForSessionId(trimmedId));
let filtered = entries;
if (typeof since === "string" && since.trim().length) {
filtered = filtered.filter((entry) => entry.timestamp >= since);
Expand Down
106 changes: 103 additions & 3 deletions apps/desktop/src/main/services/pty/ptyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3311,7 +3311,7 @@ describe("ptyService", () => {
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(createdSessionId).toBeTruthy();
expect(typeof createdSessionId).toBe("string");

service.write({ ptyId, data: "Fix the flaky login tests\r" });

Expand All @@ -3324,6 +3324,43 @@ describe("ptyService", () => {
expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests");
});

it("uses the wrapped ADE user task instead of launch guidance for CLI fallback titles", async () => {
const { service, sessionService } = createHarness();
const { ptyId } = await service.create({
laneId: "lane-1",
title: "Codex",
cols: 80,
rows: 24,
toolType: "codex",
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(typeof createdSessionId).toBe("string");

service.write({
ptyId,
data: [
"ADE session guidance. Treat this as operating guidance for the CLI session.",
"Start working on that user prompt immediately.",
"",
"User prompt:",
"You are working in ADE lane:",
"/repo/.ade/worktrees/context-iphone-17-simulator",
"",
"Edits and mutating commands must stay inside that worktree.",
"",
"The user is debugging the ADE iOS Work chat scroll/layout bugs.",
].join("\n") + "\r",
});

expect(sessionService.get(createdSessionId)?.title).toBe(
"The user is debugging the ADE iOS Work chat scroll/layout bugs",
);
expect(sessionService.get(createdSessionId)?.goal).toBe(
"The user is debugging the ADE iOS Work chat scroll/layout bugs.",
);
});

it("ignores provider slash commands when choosing the first CLI title seed", async () => {
const { service, sessionService } = createHarness();
const { ptyId } = await service.create({
Expand All @@ -3335,7 +3372,7 @@ describe("ptyService", () => {
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(createdSessionId).toBeTruthy();
expect(typeof createdSessionId).toBe("string");

service.write({ ptyId, data: "/model\r" });

Expand All @@ -3359,7 +3396,7 @@ describe("ptyService", () => {
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(createdSessionId).toBeTruthy();
expect(typeof createdSessionId).toBe("string");

service.write({ ptyId, data: "/this is a test\r" });

Expand Down Expand Up @@ -3418,6 +3455,69 @@ describe("ptyService", () => {
}
});

it("adopts Claude runtime window titles emitted by the live PTY", async () => {
const { service, mockPty, sessionService } = createHarness();
await service.create({
laneId: "lane-1",
title: "Start with context skill then i wanna redesign the ade",
cols: 80,
rows: 24,
toolType: "claude",
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(typeof createdSessionId).toBe("string");

mockPty._emitter.emit(
"data",
"\x1b]0;\u2802 Redesign ADE mobile app with unified project hub\x07",
);

expect(sessionService.get(createdSessionId)?.title).toBe(
"Redesign ADE mobile app with unified project hub",
);
expect(sessionService.updateMeta).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: createdSessionId,
title: "Redesign ADE mobile app with unified project hub",
manuallyNamed: false,
}),
);
});

it("does not let provider window titles replace ADE AI title generation", async () => {
const aiIntegrationService = {
getMode: vi.fn(() => "subscription"),
summarizeTerminal: vi.fn(async () => ({ text: "ADE generated title" })),
};
const { service, mockPty, sessionService } = createHarness({ aiIntegrationService });
await service.create({
laneId: "lane-1",
title: "Start with context skill then i wanna redesign the ade",
cols: 80,
rows: 24,
toolType: "claude",
});

const createdSessionId = (sessionService.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.sessionId;
expect(typeof createdSessionId).toBe("string");

mockPty._emitter.emit(
"data",
"\x1b]0;\u2802 Redesign ADE mobile app with unified project hub\x07",
);

expect(sessionService.get(createdSessionId)?.title).toBe(
"Start with context skill then i wanna redesign the ade",
);
expect(sessionService.updateMeta).not.toHaveBeenCalledWith(
expect.objectContaining({
sessionId: createdSessionId,
title: "Redesign ADE mobile app with unified project hub",
}),
);
});

it("sets a deterministic title immediately, then upgrades it via a deferred AI pass for Claude CLI sessions", async () => {
vi.useFakeTimers();
try {
Expand Down
104 changes: 53 additions & 51 deletions apps/desktop/src/main/services/pty/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ import type {
PtyProcessResourceUsageSnapshot,
} from "../../../shared/types";
import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands";
import { withCodexNoAltScreen } from "../../../shared/cliLaunch";
import {
sanitizeTrackedCliPromptSeed,
trackedCliTitleFromPromptSeed,
withCodexNoAltScreen,
} from "../../../shared/cliLaunch";
import { stripAnsi } from "../../utils/ansiStrip";
import { summarizeTerminalSession } from "../../utils/sessionSummary";
import { derivePreviewFromChunk } from "../../utils/terminalPreview";
Expand Down Expand Up @@ -109,8 +113,6 @@ function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolea
}

const CLI_USER_TITLE_SEED_MIN_LEN = 3;
const CLI_USER_TITLE_SEED_MAX_LEN = 180;
const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72;
const CODEX_ADE_GUIDANCE_SCAN_BYTES = 160 * 1024;
const CODEX_THREAD_NAME_SCAN_BYTES = 512 * 1024;
const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024;
Expand Down Expand Up @@ -492,52 +494,6 @@ function withAdeTerminalContextEnv(env: NodeJS.ProcessEnv, args: {
return next;
}

function sanitizeCliUserTitleSeed(raw: string): string {
const stripped = stripAnsi(raw)
.replace(/\r\n/g, "\n")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
.replace(/\n/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!stripped.length) return "";
return stripped.slice(0, CLI_USER_TITLE_SEED_MAX_LEN);
}

function trimPromptLeadIn(raw: string): string {
let text = raw.trim();
for (let i = 0; i < 4; i += 1) {
const next = text
.replace(/^(?:ok(?:ay)?|so|hey|hi|hello|please|pls|vv)\b[\s,.:;-]*/iu, "")
.trim();
if (next === text) break;
text = next;
}
return text;
}

function sentenceCase(raw: string): string {
return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : raw;
}

function deterministicCliTitleFromSeed(seed: string): string {
const naturalLanguageSlashTitle = seed.startsWith("/") && !isProviderSlashCommandInput(seed)
? seed.slice(1).trim()
: seed;
const cleaned = trimPromptLeadIn(naturalLanguageSlashTitle)
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.trim();
if (!cleaned) return "";

const clauseMatch = cleaned.match(/^(.{18,}?[,.!?;:])\s/u);
const clause = clauseMatch?.[1]?.replace(/[,.!?;:]+$/u, "").trim();
const base = clause && clause.length >= 12 ? clause : cleaned;
const clipped = base.length > CLI_USER_TITLE_FALLBACK_MAX_LEN
? base.slice(0, CLI_USER_TITLE_FALLBACK_MAX_LEN).replace(/\s+\S*$/u, "").trim()
: base;
return sentenceCase(clipped || base.slice(0, CLI_USER_TITLE_FALLBACK_MAX_LEN).trim()).replace(/[.?!,:;]+$/u, "");
}

function isCliPlaceholderTitle(title: string | null | undefined, toolType: TerminalToolType | null | undefined): boolean {
const normalized = String(title ?? "").trim().toLowerCase();
if (!normalized.length) return true;
Expand Down Expand Up @@ -578,6 +534,30 @@ function sanitizeGeneratedCliTitle(raw: string): string {
return rejected.has(collapsed) ? "" : title;
}

function extractLatestOscWindowTitle(entry: PtyEntry, data: string): string {
const combined = `${entry.runtimeWindowTitleScanBuffer}${data}`.slice(-2048);
const titlePattern = /\x1b\](?:0|2);([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
let match: RegExpExecArray | null;
let latestRawTitle = "";
let latestEnd = 0;
while ((match = titlePattern.exec(combined))) {
latestRawTitle = match[1] ?? "";
latestEnd = titlePattern.lastIndex;
}

const partialStart = combined.lastIndexOf("\x1b]");
entry.runtimeWindowTitleScanBuffer = partialStart >= latestEnd
? combined.slice(partialStart)
: "";

if (!latestRawTitle.trim()) return "";
return sanitizeGeneratedCliTitle(
latestRawTitle
.replace(/^[\s\u2800-\u28ff\u2022\u00b7.:-]+/u, "")
.trim(),
);
}

function isSessionManuallyNamed(
sessionService: ReturnType<typeof createSessionService>,
sessionId: string,
Expand Down Expand Up @@ -626,6 +606,7 @@ type PtyEntry = {
lastUserInputAt: number;
terminalSnapshot: TerminalSnapshotMirror | null;
recentOutputTail: string;
runtimeWindowTitleScanBuffer: string;
/** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */
aiTitleTimer: ReturnType<typeof setTimeout> | null;
startupTimer: ReturnType<typeof setTimeout> | null;
Expand Down Expand Up @@ -1495,7 +1476,7 @@ export function createPtyService({
if (idx === -1) break;
const segment = entry.cliUserTitleLineBuffer.slice(0, idx);
entry.cliUserTitleLineBuffer = entry.cliUserTitleLineBuffer.slice(idx + 1);
const seed = sanitizeCliUserTitleSeed(segment);
const seed = sanitizeTrackedCliPromptSeed(segment);
if (seed.length < CLI_USER_TITLE_SEED_MIN_LEN) continue;
if (isProviderSlashCommandInput(seed)) continue;

Expand All @@ -1515,7 +1496,7 @@ export function createPtyService({
return;
}
if (isCliPlaceholderTitle(session.title, session.toolType)) {
const fallbackTitle = deterministicCliTitleFromSeed(seed);
const fallbackTitle = trackedCliTitleFromPromptSeed(seed);
if (fallbackTitle) {
sessionService.updateMeta({ sessionId: entry.sessionId, title: fallbackTitle, manuallyNamed: false });
}
Expand All @@ -1540,6 +1521,25 @@ export function createPtyService({
}
};

const adoptCliRuntimeWindowTitle = (entry: PtyEntry, data: string): void => {
if (!CLI_USER_TITLE_TOOL_TYPES.has(entry.toolTypeHint ?? "shell")) return;
if (aiIntegrationService && aiIntegrationService.getMode() !== "guest" && isTitleGenerationEnabled()) return;
const title = extractLatestOscWindowTitle(entry, data);
if (!title) return;
if (isSessionManuallyNamed(sessionService, entry.sessionId)) {
logger.info("pty.cli_runtime_window_title_skipped_user_renamed", { sessionId: entry.sessionId });
return;
}
const session = sessionService.get(entry.sessionId);
if (!session || session.title?.trim() === title) return;
sessionService.updateMeta({ sessionId: entry.sessionId, title, manuallyNamed: false });
logger.info("pty.cli_runtime_window_title_adopted", {
sessionId: entry.sessionId,
toolType: entry.toolTypeHint,
titleLength: title.length,
});
};

const clearIdleTimer = (sessionId: string) => {
const state = runtimeStates.get(sessionId);
if (!state?.idleTimer) return;
Expand Down Expand Up @@ -3917,6 +3917,7 @@ export function createPtyService({
lastUserInputAt: 0,
terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null,
recentOutputTail: "",
runtimeWindowTitleScanBuffer: "",
aiTitleTimer: null,
startupTimer: null,
initialInputTimer: null,
Expand Down Expand Up @@ -3945,6 +3946,7 @@ export function createPtyService({
if (entry.disposed) return;
resyncLiveSessionRowIfNeeded(entry, ptyId);
appendRecentOutput(entry, data);
adoptCliRuntimeWindowTitle(entry, data);
writeTranscript(entry, data);
feedTerminalSnapshot(entry, data);
updatePreviewThrottled(entry, data);
Expand Down
Loading
Loading