From d18e2c2f4d412198f7d2e0c61e1ac4d400a53672 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 27 Jun 2026 15:13:10 +0200 Subject: [PATCH] feat(agent): introduced idle progress recaps and optional session titles - Added idle recap functionality to EventController to display goals and next actions when the agent is inactive. - Updated session parsing in gc-cli and memories to correctly support optional title entries in session files. --- packages/coding-agent/src/cli/gc-cli.ts | 27 +++++---- packages/coding-agent/src/memories/index.ts | 21 +++++-- .../src/modes/controllers/event-controller.ts | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/cli/gc-cli.ts b/packages/coding-agent/src/cli/gc-cli.ts index a8db55a95..0b78eec2a 100644 --- a/packages/coding-agent/src/cli/gc-cli.ts +++ b/packages/coding-agent/src/cli/gc-cli.ts @@ -434,17 +434,24 @@ function sessionIdFromSessionPath(sessionPath: string): string | undefined { } function sessionIdFromSessionText(text: string): string | undefined { - const line = text - .split(/\r?\n/) - .find(candidate => candidate.trim().length > 0) - ?.trim(); - if (!line) return undefined; - try { - const record = JSON.parse(line) as { type?: unknown; id?: unknown }; - return record.type === "session" && typeof record.id === "string" && record.id.length > 0 ? record.id : undefined; - } catch { - return undefined; + let sawTitleSlot = false; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + try { + const record = JSON.parse(line) as { type?: unknown; id?: unknown }; + if (!sawTitleSlot && record.type === "title") { + sawTitleSlot = true; + continue; + } + return record.type === "session" && typeof record.id === "string" && record.id.length > 0 + ? record.id + : undefined; + } catch { + return undefined; + } } + return undefined; } async function archivedSessionIdFromFile(file: string): Promise { diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 8d05b5a7c..e4bde7f1c 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -529,12 +529,21 @@ async function collectThreads(session: AgentSession, currentThreadId?: string): let id = name.slice(0, -6); try { const fileText = await Bun.file(fullPath).text(); - const firstLine = fileText.split("\n", 1)[0] ?? ""; - const parsed = parseJsonlLenient(firstLine); - const header = Array.isArray(parsed) && parsed.length > 0 ? (parsed[0] as Record) : undefined; - if (header && header.type === "session") { - if (typeof header.cwd === "string") cwd = header.cwd; - if (typeof header.id === "string") id = header.id; + let sawTitleSlot = false; + for (const rawLine of fileText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const parsed = parseJsonlLenient>(line); + const header = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : undefined; + if (!sawTitleSlot && header?.type === "title") { + sawTitleSlot = true; + continue; + } + if (header?.type === "session") { + if (typeof header.cwd === "string") cwd = header.cwd; + if (typeof header.id === "string") id = header.id; + } + break; } } catch { // ignore malformed session files diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index b30ac3b30..2785317bb 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -22,7 +22,9 @@ import type { InteractiveModeContext, TodoPhase } from "../../modes/types"; import type { PlanApprovalDetails } from "../../plan-mode/approved-plan"; import type { AgentSessionEvent } from "../../session/agent-session"; import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages"; +import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils"; import type { ResolveToolDetails } from "../../tools/resolve"; +import { nextActionableTask } from "../../tools/todo"; import { vocalizer } from "../../tts/vocalizer"; import { canonicalizeMessage } from "../../utils/thinking-display"; import { interruptHint } from "../shared"; @@ -43,6 +45,8 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000; * oldest live-region card retires as soon as a new one would exceed the cap. */ const MAX_LIVE_IRC_CARDS = 4; +const IDLE_RECAP_MIN_SECONDS = 1; +const IDLE_RECAP_MAX_SECONDS = 3600; type AgentSessionEventHandlers = { [E in AgentSessionEventKind]: (event: Extract) => Promise; @@ -69,6 +73,7 @@ export class EventController { // #handleMessageEnd / #handleAgentStart). #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined; #idleCompactionTimer?: NodeJS.Timeout; + #idleRecapTimer?: NodeJS.Timeout; #ircExpiryTimers = new Map(); // Insertion-ordered IRC cards not yet retired; values are the transcript // components each card contributed (see #retireIrcCard for the guard). @@ -157,6 +162,7 @@ export class EventController { this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); + this.#cancelIdleRecap(); this.#setTerminalProgress(false); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); @@ -248,6 +254,7 @@ export class EventController { this.#lastAssistantComponent = undefined; this.#pinnedErrorComponent = undefined; this.#cancelIdleCompaction(); + this.#cancelIdleRecap(); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } @@ -302,6 +309,7 @@ export class EventController { this.ctx.statusContainer.clear(); } this.#cancelIdleCompaction(); + this.#cancelIdleRecap(); this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); this.ctx.ui.requestRender(); @@ -1008,6 +1016,7 @@ export class EventController { this.#lastAssistantComponent = undefined; this.ctx.ui.requestRender(); this.#scheduleIdleCompaction(); + this.#scheduleIdleRecap(); this.sendCompletionNotification(); } @@ -1053,6 +1062,7 @@ export class EventController { event: Extract, ): Promise { this.#cancelIdleCompaction(); + this.#cancelIdleRecap(); this.#setTerminalProgress(true); this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); @@ -1085,6 +1095,7 @@ export class EventController { async #handleAutoCompactionEnd(event: Extract): Promise { this.#cancelIdleCompaction(); + this.#cancelIdleRecap(); this.#setTerminalProgress(false); if (this.ctx.autoCompactionLoader) { this.ctx.autoCompactionLoader.stop(); @@ -1237,6 +1248,13 @@ export class EventController { } } + #cancelIdleRecap(): void { + if (this.#idleRecapTimer) { + clearTimeout(this.#idleRecapTimer); + this.#idleRecapTimer = undefined; + } + } + #scheduleIdleCompaction(): void { this.#cancelIdleCompaction(); // Don't schedule idle work while context maintenance is already running; the @@ -1267,6 +1285,45 @@ export class EventController { this.#idleCompactionTimer.unref?.(); } + #scheduleIdleRecap(): void { + this.#cancelIdleRecap(); + if (this.ctx.viewSession.isCompacting) return; + + const recapSettings = settings.getGroup("recap"); + if (!recapSettings.enabled) return; + if (this.ctx.editor.getText().trim()) return; + + const timeoutMs = + Math.max(IDLE_RECAP_MIN_SECONDS, Math.min(IDLE_RECAP_MAX_SECONDS, recapSettings.idleSeconds)) * 1000; + this.#idleRecapTimer = setTimeout(() => { + this.#idleRecapTimer = undefined; + if (this.ctx.viewSession.isStreaming) return; + if (this.ctx.viewSession.isCompacting) return; + if (this.ctx.editor.getText().trim()) return; + + const message = this.#buildIdleRecapMessage(); + if (!message) return; + this.ctx.showStatus(theme.fg("dim", theme.italic(message)), { dim: false }); + }, timeoutMs); + this.#idleRecapTimer.unref?.(); + } + + #buildIdleRecapMessage(): string | undefined { + const goal = this.#idleRecapGoalText(); + const next = nextActionableTask(this.ctx.todoPhases)?.content; + const parts: string[] = []; + if (goal) parts.push(`Goal: ${previewLine(goal, TRUNCATE_LENGTHS.CONTENT)}`); + if (next) parts.push(`Next: ${previewLine(next, TRUNCATE_LENGTHS.CONTENT)}`); + return parts.length > 0 ? `※ recap: ${parts.join(" ")}` : undefined; + } + + #idleRecapGoalText(): string | undefined { + const goal = this.ctx.viewSession.getGoalModeState?.()?.goal.objective.trim(); + if (goal) return goal; + const title = this.ctx.sessionManager.getSessionName()?.trim(); + return title || undefined; + } + #currentContextTokens(): number { return this.ctx.viewSession.getContextUsage()?.tokens ?? 0; }