Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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