Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ export class FooterComponent implements Component {
const modelName = state.model?.id || "no-model";
const thinkingLevel = state.thinkingLevel || "off";
const windowDisplay = `${formatTokens(contextWindow)} window`;
let statsLine = `${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`;
const planStatus = this.session.collaborationMode === "plan" ? "[plan] " : "";
let statsLine = `${planStatus}${modelName} ${thinkingLevel} • ${contextPercentStr} • ${windowDisplay}`;

if (visibleWidth(statsLine) > width) {
statsLine = truncateToWidth(statsLine, width, "...");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
Box,
Container,
Markdown,
type MarkdownTheme,
Spacer,
stripTerminalSequences,
Text,
} from "@earendil-works/diffex-tui";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import type { PlanArtifact } from "../../../core/plan-mode.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";

/** Renders a submitted plan as a first-class transcript artifact. */
export class PlanMessageComponent extends Container {
constructor(
plan: PlanArtifact,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
) {
super();
this.addChild(new Spacer(1));

const box = new Box(outputPad, 1, (content) => theme.bg("customMessageBg", content));
const title = stripTerminalSequences(plan.title).replace(/\s+/gu, " ").trim() || "Untitled plan";
box.addChild(new Text(theme.bold(theme.fg("customMessageLabel", `Plan: ${title}`)), 0, 0));
box.addChild(new Spacer(1));
box.addChild(
new Markdown(
plan.markdown,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown renderer omits source metadata such as link titles, while acceptance later sends the raw plan.markdown to the implementation model. Please render or canonicalize the exact representation that will be sent so the user cannot approve a view that hides instructions.

0,
0,
markdownTheme,
{ color: (content) => theme.fg("customMessageText", content) },
{
preserveOrderedListMarkers: true,
preserveBackslashEscapes: true,
transform: createMarkdownTransform("assistant", false, markdownTransformers),
},
),
);
this.addChild(box);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ class TreeList implements Component {
entry.type === "custom" ||
entry.type === "model_change" ||
entry.type === "thinking_level_change" ||
entry.type === "collaboration_mode_change" ||
entry.type === "plan_resolution" ||
entry.type === "session_info";

switch (this.filterMode) {
Expand Down Expand Up @@ -605,6 +607,15 @@ class TreeList implements Component {
case "thinking_level_change":
parts.push("thinking", entry.thinkingLevel);
break;
case "collaboration_mode_change":
parts.push("collaboration mode", entry.mode);
break;
case "plan":
parts.push("plan", entry.plan.title, entry.plan.markdown);
break;
case "plan_resolution":
parts.push("plan resolution", entry.resolution, entry.planId);
break;
case "custom":
parts.push("custom", entry.customType);
break;
Expand Down Expand Up @@ -835,6 +846,15 @@ class TreeList implements Component {
case "thinking_level_change":
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
break;
case "collaboration_mode_change":
result = theme.fg("dim", `[collaboration mode: ${entry.mode}]`);
break;
case "plan":
result = theme.fg("accent", "[plan]: ") + normalize(entry.plan.title);
break;
case "plan_resolution":
result = theme.fg("dim", `[plan resolution: ${entry.resolution}]`);
break;
case "custom":
result = theme.fg("dim", `[custom: ${entry.customType}]`);
break;
Expand Down Expand Up @@ -919,6 +939,15 @@ class TreeList implements Component {
case "branch_summary":
text = entry.summary;
break;
case "collaboration_mode_change":
text = `Collaboration mode: ${entry.mode}`;
break;
case "plan":
text = `${entry.plan.title}\n\n${entry.plan.markdown}`;
break;
case "plan_resolution":
text = `Plan ${entry.planId}: ${entry.resolution}`;
break;
}

return text?.trim() ? text : undefined;
Expand Down
114 changes: 97 additions & 17 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ import {
formatAuthSelectorProviderType,
OAuthSelectorComponent,
} from "./components/oauth-selector.ts";
import { PlanMessageComponent } from "./components/plan-message.ts";
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";
import { SessionSelectorComponent } from "./components/session-selector.ts";
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
Expand Down Expand Up @@ -225,7 +226,7 @@ type CompactionQueuedMessage = {
mode: "steer" | "followUp";
};

type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" | "plan" }>;

function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
return "type" in item && item.type === "custom";
Expand Down Expand Up @@ -1080,6 +1081,22 @@ export class InteractiveMode {

// Show startup warnings
const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
const preserveStartupMessages = (messages: string[], imageCount = 0, notice?: string): void => {
if (messages.length === 0 && imageCount === 0 && !notice) return;
const waitingText = messages.join("\n\n");
const existingText = this.editor.getText();
if (waitingText) {
this.editor.setText(existingText.trim() ? `${existingText}\n\n${waitingText}` : waitingText);
}
const promptNotice = messages.length
? `${messages.length} startup ${messages.length === 1 ? "prompt is" : "prompts are"} waiting in the editor${notice ? "." : " for plan resolution."}`
: "";
const imageNotice = imageCount
? `${imageCount} attached startup ${imageCount === 1 ? "image" : "images"} must be reattached ${notice ? "before sending" : "after plan resolution"}.`
: "";
this.showStatus([notice, promptNotice, imageNotice].filter(Boolean).join(" "));
this.ui.requestRender();
};

if (migratedProviders && migratedProviders.length > 0) {
this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
Expand All @@ -1097,25 +1114,42 @@ export class InteractiveMode {
void this.maybeWarnAboutAnthropicSubscriptionAuth();

// Process initial messages
if (initialMessage) {
try {
await this.promptUser(initialMessage, { images: initialImages });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}

if (initialMessages) {
for (const message of initialMessages) {
const pendingPlanContinuation = this.session.pendingPlanContinuation;
if (this.session.pendingPlan || pendingPlanContinuation) {
preserveStartupMessages(
[...(initialMessage ? [initialMessage] : []), ...(initialMessages ?? [])],
initialImages?.length,
pendingPlanContinuation ? "Accepted plan implementation is waiting. Use /plan to retry." : undefined,
);
} else {
if (initialMessage) {
try {
await this.promptUser(message);
await this.promptUser(initialMessage, { images: initialImages });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
}

if (this.session.pendingPlan) {
preserveStartupMessages(initialMessages ?? []);
} else if (initialMessages) {
for (let index = 0; index < initialMessages.length; index++) {
const message = initialMessages[index];
try {
await this.promptUser(message);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
if (this.session.pendingPlan) {
preserveStartupMessages(initialMessages.slice(index + 1));
break;
}
}
}
}
this.openPendingPlanDecision();

// Main interactive loop
while (true) {
Expand All @@ -1126,6 +1160,7 @@ export class InteractiveMode {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
this.showError(errorMessage);
}
this.openPendingPlanDecision();
}
}

Expand Down Expand Up @@ -1906,6 +1941,7 @@ export class InteractiveMode {

this.chatContainer.clear();
this.renderInitialMessages();
this.openPendingPlanDecision();
if (result.editorText && !this.editor.getText().trim()) {
this.editor.setText(result.editorText);
}
Expand Down Expand Up @@ -1992,6 +2028,12 @@ export class InteractiveMode {
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
if (options.renderBeforeBind) {
if (this.session.pendingPlanContinuation && !this.activeEditorUi?.preserveOnSessionRebind) {
this.showStatus("Accepted plan implementation is waiting. Use /plan to retry.");
}
this.openPendingPlanDecision();
}
}

private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {
Expand Down Expand Up @@ -2562,6 +2604,18 @@ export class InteractiveMode {
return result === "Yes";
}

private renderPlanArtifact(plan: PlanArtifact): void {
this.chatContainer.addChild(
new PlanMessageComponent(
plan,
this.getMarkdownThemeWithSettings(),
this.outputPad,
this.getMarkdownTransformers(),
),
);
this.ui.requestRender();
}

private cancelPendingPlanDecision(): void {
const controller = this.planDecisionAbortController;
this.planDecisionAbortController = undefined;
Expand Down Expand Up @@ -3321,9 +3375,13 @@ export class InteractiveMode {
if (this.session.isStreaming) {
this.editor.addToHistory?.(text);
this.editor.setText("");
await this.promptUser(text, { streamingBehavior: "steer" });
this.updatePendingMessagesDisplay();
this.ui.requestRender();
try {
await this.promptUser(text, { streamingBehavior: "steer" });
} finally {
this.updatePendingMessagesDisplay();
this.ui.requestRender();
this.openPendingPlanDecision();
}
return;
}

Expand Down Expand Up @@ -3399,6 +3457,19 @@ export class InteractiveMode {
this.updateEditorBorderColor();
break;

case "collaboration_mode_changed":
this.ui.requestRender();
break;

case "plan_submitted":
this.renderPlanArtifact(event.plan);
break;

case "plan_resolved":
if (this.suppressedPlanDecisionId === event.planId) this.suppressedPlanDecisionId = undefined;
this.ui.requestRender();
break;

case "message_start":
if (event.message.role === "custom") {
this.addMessageToChat(event.message);
Expand Down Expand Up @@ -3891,6 +3962,10 @@ export class InteractiveMode {
this.addCustomEntryToChat(item);
continue;
}
if ("type" in item && item.type === "plan") {
this.renderPlanArtifact(item.plan);
continue;
}

const message = item;
// Assistant messages need special handling for tool calls
Expand Down Expand Up @@ -3965,12 +4040,16 @@ export class InteractiveMode {
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
const items = entries.flatMap((entry): RenderSessionItem[] => {
if (entry.type === "custom") {
if (entry.type === "custom" || entry.type === "plan") {
return [entry];
}
return sessionEntryToContextMessages(entry);
});
this.renderSessionItems(items, options);
const pendingPlan = this.session.pendingPlan;
if (pendingPlan && !entries.some((entry) => entry.type === "plan" && entry.plan.id === pendingPlan.id)) {
this.renderPlanArtifact(pendingPlan);
}
}

/**
Expand Down Expand Up @@ -5345,6 +5424,7 @@ export class InteractiveMode {
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
this.openPendingPlanDecision();
if (result.editorText && !this.editor.getText().trim()) {
this.editor.setText(result.editorText);
}
Expand Down
16 changes: 16 additions & 0 deletions packages/coding-agent/test/footer-width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function createSession(options: {
compactionUsage?: AssistantUsage;
toolUsage?: AssistantUsage;
usingSubscription?: boolean;
collaborationMode?: "default" | "plan";
}): AgentSession {
const usage = options.usage;
const entries: Array<Record<string, unknown>> = [];
Expand Down Expand Up @@ -82,6 +83,7 @@ function createSession(options: {
modelRuntime: {
isUsingSubscription: () => options.usingSubscription ?? false,
},
collaborationMode: options.collaborationMode ?? "default",
};

return session as unknown as AgentSession;
Expand Down Expand Up @@ -172,4 +174,18 @@ describe("FooterComponent width handling", () => {

expect(stripAnsi(footer.render(120).join("\n"))).not.toContain("/tmp/project");
});

it("keeps the plan-mode status visible at 60 columns", () => {
const session = createSession({
sessionName: "",
modelId: "claude-opus-4-1",
thinkingLevel: "high",
collaborationMode: "plan",
});
const footer = new FooterComponent(session, createFooterData(1));

const statsLine = stripAnsi(footer.render(60)[0]);
expect(statsLine.startsWith("[plan] ")).toBe(true);
expect(visibleWidth(statsLine)).toBeLessThanOrEqual(60);
});
});
Loading