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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Fixed

- Deferred composer shell results now commit to the session transcript before terminal turn publication, reconcile uncertain append outcomes by exact entry id, and retire only the matching live display during transcript rebuilds so persisted executions are neither lost nor duplicated (#3639).
- ACP session configuration now emits the spec-defined `category` field on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), so standards-compliant ACP clients such as Paseo discover models, modes, and thinking levels instead of an empty model picker (#3922).
- The ACP session model catalog is now filtered to active providers via `providers.list/active`, falling back to the full catalog on older session hosts, so ACP clients no longer list models for providers without usable credentials (#3922).

Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/modes/components/bash-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export class BashExecutionComponent extends Container {
this.#contentContainer.addChild(this.#headerText);
this.#contentContainer.addChild(this.#loader);
}
get isRunning(): boolean {
return this.#status === "running";
}

/**
* Set whether the output is expanded (shows full output) or collapsed (preview only).
Expand Down
51 changes: 23 additions & 28 deletions packages/coding-agent/src/modes/controllers/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { getDisplayChangelogEntries } from "../../utils/changelog";
import { copyToClipboard } from "../../utils/clipboard";
import { openPath } from "../../utils/open";
import { setSessionTerminalTitle } from "../../utils/title-generator";
import { prepareTranscriptRebuild } from "../utils/ui-helpers";
import { associatePendingBashComponent, prepareTranscriptRebuild } from "../utils/ui-helpers";

function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown: string): void {
ctx.chatContainer.addChild(new Spacer(1));
Expand Down Expand Up @@ -1143,46 +1143,41 @@ export class CommandController {

async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
const bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
const displayIdentity = {};
this.ctx.bashComponent = bashComponent;

if (isDeferred) {
this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent);
this.ctx.pendingBashComponents.push(this.ctx.bashComponent);
associatePendingBashComponent(bashComponent, displayIdentity);
this.ctx.pendingMessagesContainer.addChild(bashComponent);
this.ctx.pendingBashComponents.push(bashComponent);
} else {
this.ctx.chatContainer.addChild(this.ctx.bashComponent);
this.ctx.chatContainer.addChild(bashComponent);
}
this.ctx.ui.requestRender();

try {
const result = await this.ctx.session.executeBash(
command,
chunk => {
if (this.ctx.bashComponent) {
this.ctx.bashComponent.appendOutput(chunk);
}
},
{ excludeFromContext },
);
const result = await this.ctx.session.executeBash(command, chunk => bashComponent.appendOutput(chunk), {
excludeFromContext,
displayIdentity,
});

if (this.ctx.bashComponent) {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
this.ctx.bashComponent.setComplete(result.exitCode, result.cancelled, {
output: result.output,
truncation: meta?.truncation,
});
}
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
bashComponent.setComplete(result.exitCode, result.cancelled, {
output: result.output,
truncation: meta?.truncation,
});
} catch (error) {
if (this.ctx.bashComponent) {
this.ctx.bashComponent.setComplete(undefined, false);
}
bashComponent.setComplete(undefined, false);
this.ctx.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
const bashComponent = this.ctx.bashComponent;
if (isDeferred && bashComponent && this.ctx.pendingBashComponents.includes(bashComponent)) {
const pendingIndex = this.ctx.pendingBashComponents.indexOf(bashComponent);
if (isDeferred && !this.ctx.session.isStreaming && pendingIndex !== -1) {
this.ctx.pendingMessagesContainer.detachChild(bashComponent);
this.ctx.pendingBashComponents.splice(pendingIndex, 1);
this.ctx.chatContainer.addChild(bashComponent);
}

this.ctx.bashComponent = undefined;
if (this.ctx.bashComponent === bashComponent) this.ctx.bashComponent = undefined;
this.ctx.ui.requestRender();
}

Expand Down
95 changes: 85 additions & 10 deletions packages/coding-agent/src/modes/utils/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,67 @@ function isActiveChatChild(ctx: InteractiveModeContext, component: Component): b
return component === ctx.bashComponent || component === ctx.pythonComponent || component === ctx.streamingComponent;
}

const pendingBashComponentIdentities = new WeakMap<BashExecutionComponent, object>();

export function associatePendingBashComponent(component: BashExecutionComponent, identity: object): void {
pendingBashComponentIdentities.set(component, identity);
}

function isPersistedBashComponent(
ctx: InteractiveModeContext,
component: BashExecutionComponent,
persistedBashEntryIds: ReadonlySet<string>,
): boolean {
const identity = pendingBashComponentIdentities.get(component);
const entryId = identity && ctx.session.getBashExecutionEntryId(identity);
return entryId !== undefined && persistedBashEntryIds.has(entryId);
}

function detachPendingExecutionComponents(
ctx: InteractiveModeContext,
persistedBashEntryIds: ReadonlySet<string>,
): BashExecutionComponent[] {
const retained: BashExecutionComponent[] = [];
for (const component of ctx.pendingBashComponents) {
ctx.pendingMessagesContainer.detachChild(component);
if (isPersistedBashComponent(ctx, component, persistedBashEntryIds)) {
pendingBashComponentIdentities.delete(component);
if (ctx.bashComponent === component) ctx.bashComponent = undefined;
continue;
}
retained.push(component);
}
const activeComponent = ctx.bashComponent;
if (activeComponent && isPersistedBashComponent(ctx, activeComponent, persistedBashEntryIds)) {
if (ctx.pendingMessagesContainer.children.includes(activeComponent)) {
ctx.pendingMessagesContainer.detachChild(activeComponent);
}
if (ctx.chatContainer.children.includes(activeComponent)) {
ctx.chatContainer.detachChild(activeComponent);
}
pendingBashComponentIdentities.delete(activeComponent);
ctx.bashComponent = undefined;
}
return retained;
}

function restorePendingExecutionComponents(ctx: InteractiveModeContext, components: BashExecutionComponent[]): void {
ctx.pendingBashComponents = components;
for (const component of components) ctx.pendingMessagesContainer.addChild(component);
}
function detachPendingPythonComponents(ctx: InteractiveModeContext): EvalExecutionComponent[] {
const components = ctx.pendingPythonComponents.filter(component =>
ctx.pendingMessagesContainer.children.includes(component),
);
for (const component of components) ctx.pendingMessagesContainer.detachChild(component);
return components;
}

function restorePendingPythonComponents(ctx: InteractiveModeContext, components: EvalExecutionComponent[]): void {
ctx.pendingPythonComponents = components;
for (const component of components) ctx.pendingMessagesContainer.addChild(component);
}

function getChatChildTime(component: Component): number {
return chatChildAddedAt.get(component) ?? Date.now();
}
Expand Down Expand Up @@ -898,18 +959,26 @@ export class UiHelpers {
renderInitialMessages(prebuiltContext?: SessionContext, options: RenderInitialMessagesOptions = {}): void {
// This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
// Clear existing rendered chat first to avoid duplicating the full session in the container.
const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
// A persisted execution owns its transcript position. Retire only its matching
// live component; still-running executions remain on the pending surface.
const persistedBashEntryIds = new Set(
context.messages
.filter(message => message.role === "bashExecution")
.map(message => getSessionMessageEntryId(message))
.filter((entryId): entryId is string => entryId !== undefined),
);
const pendingBashComponents = detachPendingExecutionComponents(this.ctx, persistedBashEntryIds);
const pendingPythonComponents = detachPendingPythonComponents(this.ctx);
const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];

// Reuse a pre-built context when available (e.g. from navigateTree) to avoid a second O(N) walk.
const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
this.ctx.renderSessionContext(context, {
updateFooter: true,
populateHistory: true,
});
restorePendingExecutionComponents(this.ctx, pendingBashComponents);
restorePendingPythonComponents(this.ctx, pendingPythonComponents);

// Show compaction info if session was compacted
const allEntries = this.ctx.sessionManager.getEntries();
Expand Down Expand Up @@ -979,6 +1048,8 @@ export class UiHelpers {
}

updatePendingMessagesDisplay(): void {
const pendingBashComponents = detachPendingExecutionComponents(this.ctx, new Set());
const pendingPythonComponents = detachPendingPythonComponents(this.ctx);
this.ctx.pendingMessagesContainer.clear();
const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages;

Expand Down Expand Up @@ -1015,6 +1086,8 @@ export class UiHelpers {
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
}
}
restorePendingExecutionComponents(this.ctx, pendingBashComponents);
restorePendingPythonComponents(this.ctx, pendingPythonComponents);
}

queueCompactionMessage(text: string, mode: "steer" | "followUp", options?: ComposerSubmissionOptions): void {
Expand Down Expand Up @@ -1237,16 +1310,18 @@ export class UiHelpers {
}
}

/** Move pending bash components from pending area to chat */
/** Move completed pending Bash components from the pending area to chat. */
flushPendingBashComponents(): void {
// Move (detach, not dispose) the live execution components from the pending
// area into the chat transcript — they are reused instances, so a disposing
// removeChild() would tear them down before re-adding.
const retained: BashExecutionComponent[] = [];
for (const component of this.ctx.pendingBashComponents) {
if (component.isRunning) {
retained.push(component);
continue;
}
this.ctx.pendingMessagesContainer.detachChild(component);
addChatChild(this.ctx, component);
}
this.ctx.pendingBashComponents = [];
this.ctx.pendingBashComponents = retained;
for (const component of this.ctx.pendingPythonComponents) {
this.ctx.pendingMessagesContainer.detachChild(component);
addChatChild(this.ctx, component);
Expand Down
Loading
Loading