fix(coding-agent): split /clone from /fork UX

closes #2962
This commit is contained in:
Mario Zechner
2026-04-20 14:33:32 +02:00
parent 62c1c4031c
commit d554409b1f
16 changed files with 386 additions and 34 deletions

View File

@@ -120,8 +120,14 @@ export class UserMessageSelectorComponent extends Container {
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(theme.bold("Branch from Message"), 1, 0));
this.addChild(new Text(theme.fg("muted", "Select a message or duplicate the current session"), 1, 0));
this.addChild(new Text(theme.bold("Fork from Message"), 1, 0));
this.addChild(
new Text(
theme.fg("muted", "Select a user message to copy the active path up to that point into a new session"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));

View File

@@ -2425,6 +2425,11 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/clone") {
this.editor.setText("");
await this.handleCloneCommand();
return;
}
if (text === "/tree") {
this.showTreeSelector();
this.editor.setText("");
@@ -3963,7 +3968,6 @@ export class InteractiveMode {
}
private showUserMessageSelector(): void {
const duplicateSessionOptionId = "__duplicate_session__";
const userMessages = this.session.getUserMessagesForForking();
if (userMessages.length === 0) {
@@ -3971,28 +3975,14 @@ export class InteractiveMode {
return;
}
const selectableEntries = [
...userMessages.map((m) => ({ id: m.entryId, text: m.text })),
{ id: duplicateSessionOptionId, text: "Duplicate session" },
];
const initialSelectedId = userMessages[userMessages.length - 1]?.entryId;
this.showSelector((done) => {
const selector = new UserMessageSelectorComponent(
selectableEntries,
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
try {
let result: { cancelled: boolean; selectedText?: string };
if (entryId === duplicateSessionOptionId) {
const leafId = this.sessionManager.getLeafId();
if (!leafId) {
throw new Error("Cannot duplicate session: no current entry selected");
}
result = await this.runtimeHost.fork(leafId, { position: "at" });
} else {
result = await this.runtimeHost.fork(entryId);
}
const result = await this.runtimeHost.fork(entryId);
if (result.cancelled) {
done();
this.ui.requestRender();
@@ -4003,7 +3993,7 @@ export class InteractiveMode {
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Branched to new session");
this.showStatus("Forked to new session");
} catch (error: unknown) {
done();
this.showError(error instanceof Error ? error.message : String(error));
@@ -4019,6 +4009,29 @@ export class InteractiveMode {
});
}
private async handleCloneCommand(): Promise<void> {
const leafId = this.sessionManager.getLeafId();
if (!leafId) {
this.showStatus("Nothing to clone yet");
return;
}
try {
const result = await this.runtimeHost.fork(leafId, { position: "at" });
if (result.cancelled) {
this.ui.requestRender();
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText("");
this.showStatus("Cloned to new session");
} catch (error: unknown) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private showTreeSelector(initialSelectedId?: string): void {
const tree = this.sessionManager.getTree();
const realLeafId = this.sessionManager.getLeafId();

View File

@@ -342,6 +342,15 @@ export class RpcClient {
return this.getData(response);
}
/**
* Clone the current active branch into a new session.
* @returns Object with `cancelled: true` if an extension cancelled the clone
*/
async clone(): Promise<{ cancelled: boolean }> {
const response = await this.send({ type: "clone" });
return this.getData(response);
}
/**
* Get messages available for forking.
*/

View File

@@ -567,6 +567,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
}
case "clone": {
const leafId = session.sessionManager.getLeafId();
if (!leafId) {
return error(id, "clone", "Cannot clone session: no current entry selected");
}
const result = await runtimeHost.fork(leafId, { position: "at" });
if (!result.cancelled) {
await rebindSession();
}
return success(id, "clone", { cancelled: result.cancelled });
}
case "get_fork_messages": {
const messages = session.getUserMessagesForForking();
return success(id, "get_fork_messages", { messages });

View File

@@ -57,6 +57,7 @@ export type RpcCommand =
| { id?: string; type: "export_html"; outputPath?: string }
| { id?: string; type: "switch_session"; sessionPath: string }
| { id?: string; type: "fork"; entryId: string }
| { id?: string; type: "clone" }
| { id?: string; type: "get_fork_messages" }
| { id?: string; type: "get_last_assistant_text" }
| { id?: string; type: "set_session_name"; name: string }
@@ -172,6 +173,7 @@ export type RpcResponse =
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
| { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } }
| { id?: string; type: "response"; command: "clone"; success: true; data: { cancelled: boolean } }
| {
id?: string;
type: "response";