Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ benchmarks/baseline.json
.vibe/
.windsurf/
.zencoder/

# Python bytecode caches from scripts/
__pycache__/
*.pyc
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ fx session rewind <id> --by 2

Rewound turns are not erased. The rewind is recorded as a new revision in the session's event log, and the files those turns referenced stay on disk.

The interactive shell has the same two operations for the session it is already in:

```
/fork 7
/rewind 2
```

`/fork 7` branches at turn 7, names both the source ID and the new one, and leaves you in the branch. The source session keeps every turn it had. `/rewind 2` asks first: the message says how many turns it will drop and how many remain, and a second identical `/rewind 2` carries it out. Any other command in between cancels it.

Neither command reverts file edits, commands, commits, or API calls. They change the conversation only.

Each interactive session names its terminal tab. The title prefers the session name, falls back to the workspace name, and keeps the active model as secondary context. Renaming or resuming a session updates the tab, and exiting clears the fx-owned title. Noninteractive commands do not emit terminal-title controls.

Run `/feedback` to open the feedback form at `fx.sh/feedback`. It does not create a diagnostic or change the clipboard.
Expand Down
1 change: 1 addition & 0 deletions scripts/pgso/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"verification_scenarios": [
{"name": "verify-auto-mode-reliability", "argv": ["bun", "test", "--max-concurrency", "1", "./auto-mode-reliability.test.ts"], "test_file": "auto-mode-reliability.test.ts"},
{"name": "verify-session-fork", "argv": ["bun", "test", "--max-concurrency", "1", "./session-fork.test.ts"], "test_file": "session-fork.test.ts", "requires_tmux": false},
{"name": "verify-tui-session-fork", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-session-fork.test.ts"], "test_file": "tui-session-fork.test.ts", "requires_tmux": true},
{"name": "verify-oauth-keychain-migration", "argv": ["bun", "test", "--max-concurrency", "1", "./oauth-keychain-migration.test.ts"], "test_file": "oauth-keychain-migration.test.ts", "allow_keychain": true},
{"name": "verify-tui-auth-source-selection", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-auth-source-selection.test.ts"], "test_file": "tui-auth-source-selection.test.ts"},
{"name": "verify-tui-composer-edit-contracts", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-composer-edit-contracts.test.ts"], "test_file": "tui-composer-edit-contracts.test.ts"},
Expand Down
4 changes: 4 additions & 0 deletions src/builtins/commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ pub const slash_specs = [_]SlashSpec{
.{ .kind = .resume_session, .command = "/resume", .help_entry = "/resume", .completion_description = "resume a saved session", .presentation_category = .session },
.{ .kind = .continue_recovery, .command = "/continue", .help_entry = "/continue", .completion_description = "continue a paused model response", .presentation_category = .session, .requires_prompt_credential = true },
.{ .kind = .rename_session, .command = "/rename", .help_entry = "/rename <title>", .completion_description = "rename the current session", .presentation_category = .session, .has_args = true, .accepts_payload = true },
.{ .kind = .fork_session, .command = "/fork", .help_entry = "/fork <turn>", .completion_description = "branch this session at a turn into a new one", .presentation_category = .session, .has_args = true, .accepts_payload = true },
.{ .kind = .rewind_session, .command = "/rewind", .help_entry = "/rewind <count>", .completion_description = "drop the last turns from this session", .presentation_category = .session, .has_args = true, .accepts_payload = true },
.{ .kind = .login, .command = "/login", .help_entry = "/login", .completion_description = "choose Vercel or Codex sign-in", .presentation_category = .account },
.{ .kind = .logout, .command = "/logout", .help_entry = "/logout [vercel|codex|grok]", .completion_description = "sign out of a provider session", .presentation_category = .account, .has_args = true, .accepts_payload = true },
.{ .kind = .setup, .command = "/setup", .help_entry = "/setup", .completion_description = "manage accounts and AI Gateway access", .presentation_category = .account },
Expand Down Expand Up @@ -542,6 +544,8 @@ test "built-in slash commands register exact active order" {
"/resume",
"/continue",
"/rename",
"/fork",
"/rewind",
"/login",
"/logout",
"/setup",
Expand Down
233 changes: 232 additions & 1 deletion src/core/app/app_commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,15 @@ fn requestResumeExit(app: anytype) void {
pub fn Handlers(comptime App: type) type {
return struct {
pub fn route(app: *App, cmd: []const u8) !void {
const parsed = command_router.parse(app.slashRegistry(), cmd);
if (comptime @hasField(App, "session_persistence")) {
switch (parsed) {
.rewind_session => {},
else => app_session_runtime.Runtime(App).disarmRewind(app),
}
}
const handlers = commandHandlers(app);
try command_router.route(app.slashRegistry(), &handlers, cmd);
try command_router.dispatch(&handlers, parsed, cmd);
}

pub fn commandHandlers(app: *App) command_router.CommandHandlers {
Expand Down Expand Up @@ -382,6 +389,8 @@ pub fn Handlers(comptime App: type) type {
.toggle_fast = commandToggleFast,
.handle_statusline = commandHandleStatusline,
.rename_session = commandRenameSession,
.fork_session = commandForkSession,
.rewind_session = commandRewindSession,
.handle_notifications = commandHandleNotifications,
.handle_workspace = commandHandleWorkspace,
.show_version = commandShowVersion,
Expand Down Expand Up @@ -683,6 +692,16 @@ pub fn Handlers(comptime App: type) type {
try handleRenameCommand(app, rest);
}

fn commandForkSession(ctx: *anyopaque, rest: []const u8) !void {
const app: *App = @ptrCast(@alignCast(ctx));
try handleForkCommand(app, rest);
}

fn commandRewindSession(ctx: *anyopaque, rest: []const u8) !void {
const app: *App = @ptrCast(@alignCast(ctx));
try handleRewindCommand(app, rest);
}

fn commandShowHelp(ctx: *anyopaque) !void {
const app: *App = @ptrCast(@alignCast(ctx));
if (comptime @hasField(App, "skills")) app.skills.closeMenu();
Expand Down Expand Up @@ -3431,6 +3450,218 @@ fn handleRenameCommand(app: anytype, rest: []const u8) !void {
try app.writeDomainNotice(.{ .topic = "session", .tone = .neutral, .body = msg }, true);
}

fn turnPlural(count: usize) []const u8 {
return if (count == 1) "" else "s";
}

fn parsedTurnCount(rest: []const u8) ?usize {
const trimmed = std.mem.trim(u8, rest, " \t");
if (trimmed.len == 0) return null;
const value = std.fmt.parseInt(usize, trimmed, 10) catch return null;
return if (value == 0) null else value;
}

/// Points at the command that lists the turn numbers, naming the live session
/// so the reader can paste the line as written.
fn writeTurnArgumentUsage(app: anytype, usage: []const u8) !void {
const App = @TypeOf(app.*);
const id = app_session_runtime.Runtime(App).activeSessionId(app) orelse "<id>";
const body = try std.fmt.allocPrint(
app.alloc,
"Use: {s}. Run `fx session {s}` to see turn numbers.",
.{ usage, id },
);
defer app.alloc.free(body);
try app.writeDomainNotice(
.{ .topic = "session", .tone = .@"error", .body = body },
true,
);
}

fn handleForkCommand(app: anytype, rest: []const u8) !void {
const App = @TypeOf(app.*);
const SessionRuntime = app_session_runtime.Runtime(App);

const at_turn = parsedTurnCount(rest) orelse {
try writeTurnArgumentUsage(app, "/fork <turn>");
return;
};

var outcome = try SessionRuntime.forkLiveSession(app, at_turn);
defer outcome.deinit(app.alloc);

switch (outcome) {
.unavailable_during_stream => try app.writeDomainNotice(.{
.topic = "session",
.tone = .neutral,
.body = "fork is unavailable until the response finishes",
}, true),
.unavailable => try app.writeDomainNotice(.{
.topic = "session",
.tone = .@"error",
.body = "no saved session to fork",
}, true),
.out_of_range => |history_len| {
const body = try std.fmt.allocPrint(
app.alloc,
"turn {d} is out of range; session has {d} turn{s}",
.{ at_turn, history_len, turnPlural(history_len) },
);
defer app.alloc.free(body);
try app.writeDomainNotice(
.{ .topic = "session", .tone = .@"error", .body = body },
true,
);
},
.branched => |branch| {
var out: std.Io.Writer.Allocating = .init(app.alloc);
defer out.deinit();
try out.writer.print(
"Forked {s} at turn {d} into {s}. " ++
"You are now in the branch; {s} is unchanged.",
.{
branch.source_id,
branch.retained_turns,
branch.forked_id,
branch.source_id,
},
);
if (branch.unverified_artifacts) {
try out.writer.writeAll(
" Some legacy command artifacts could not be authenticated.",
);
}
try app.writeDomainNotice(
.{ .topic = "session", .tone = .neutral, .body = out.written() },
true,
);
app.shell.render_requests.request(.footer);
},
.failed => |failure| try writeForkFailureNotice(app, failure),
}
}

/// A failed fork still names every id involved, because the reader has to know
/// whether a branch exists and which session this shell ended up on.
fn writeForkFailureNotice(
app: anytype,
failure: app_session_runtime.Runtime(@TypeOf(app.*)).ForkOutcome.Failure,
) !void {
var out: std.Io.Writer.Allocating = .init(app.alloc);
defer out.deinit();

if (failure.forked_id) |forked_id| {
try out.writer.print(
"Forked {s} into {s} but could not open it ({s}); run `fx --resume {s}`.",
.{
failure.source_id,
forked_id,
@errorName(failure.problem),
forked_id,
},
);
} else {
try out.writer.print(
"Could not fork {s} ({s}).",
.{ failure.source_id, @errorName(failure.problem) },
);
}

switch (failure.landing) {
.source => try out.writer.print(
" This shell is still on {s}.",
.{failure.source_id},
),
.fresh_session => try out.writer.writeAll(
" This shell is on a new empty session.",
),
.no_session => try out.writer.writeAll(
" This shell has no session; run /resume to open one.",
),
}

try app.writeDomainNotice(
.{ .topic = "session", .tone = .warning, .body = out.written() },
true,
);
app.shell.render_requests.request(.footer);
}

fn handleRewindCommand(app: anytype, rest: []const u8) !void {
const App = @TypeOf(app.*);
const SessionRuntime = app_session_runtime.Runtime(App);

const requested = parsedTurnCount(rest) orelse {
try writeTurnArgumentUsage(app, "/rewind <count>");
return;
};

switch (try SessionRuntime.rewindLiveSession(app, requested)) {
.unavailable_during_stream => try app.writeDomainNotice(.{
.topic = "session",
.tone = .neutral,
.body = "rewind is unavailable until the response finishes",
}, true),
.out_of_range => |history_len| {
const body = try std.fmt.allocPrint(
app.alloc,
"cannot rewind {d} turn{s}; session has {d} turn{s}",
.{
requested,
turnPlural(requested),
history_len,
turnPlural(history_len),
},
);
defer app.alloc.free(body);
try app.writeDomainNotice(
.{ .topic = "session", .tone = .@"error", .body = body },
true,
);
},
.confirm => |target| {
var count_buf: [40]u8 = undefined;
const dropped = if (target.removedTurnCount() == 1)
"the last turn"
else
try std.fmt.bufPrint(
&count_buf,
"the last {d} turns",
.{target.removedTurnCount()},
);
const body = try std.fmt.allocPrint(
app.alloc,
"/rewind {d} drops {s} and leaves {d}. " ++
"Run /rewind {d} again to confirm. File changes are not reverted.",
.{ requested, dropped, target.retained_turns, requested },
);
defer app.alloc.free(body);
try app.writeDomainNotice(
.{ .topic = "session", .tone = .warning, .body = body },
true,
);
},
.rewound => |target| {
const body = try std.fmt.allocPrint(
app.alloc,
"Rewound {d} turn{s}; {d} turn{s} left. File changes were not reverted.",
.{
target.removedTurnCount(),
turnPlural(target.removedTurnCount()),
target.retained_turns,
turnPlural(target.retained_turns),
},
);
defer app.alloc.free(body);
try app.writeDomainNotice(
.{ .topic = "session", .tone = .neutral, .body = body },
true,
);
app.shell.render_requests.request(.footer);
},
}
}

const StatuslineFeedback = enum { announce, silent };

fn parseStatuslineItem(raw: []const u8) ?config_runtime.StatuslineItem {
Expand Down
Loading