const std = @import("std");const debug_trace = @import("../shared/debug_trace.zig");const edit_history = @import("edit_history.zig");const editor_state = @import("editor_state.zig");const input_limit_rejection = @import("input_limit_rejection.zig");const input_reset = @import("input_reset.zig");const io_mod = @import("../shared/io.zig");const pasted_blocks = @import("pasted_blocks.zig");const picker_state = @import("picker_state.zig");const registered_entities = @import("registered_entities.zig");const vertical_navigation = @import("vertical_navigation.zig"); const Allocator = std.mem.Allocator; /// Owns staged replacement bytes before commit and the displaced editor buffer/// afterward. Always call `deinit` with the allocator used by `prepare`.pub const Prepared = struct { input: std.ArrayList(u8) = .empty, dropped_entities: DroppedEntities = .{}, pub fn deinit(self: *Prepared, alloc: Allocator) void { self.input.deinit(alloc); self.* = .{}; }}; const DroppedEntities = struct { pasted_blocks: usize = 0, image_tokens: usize = 0, skill_tokens: usize = 0,}; /// Borrows the Core state changed by one whole-composer text replacement./// The containing input runtime retains ownership of every pointer and allocation.pub const State = struct { edit: *editor_state.State, history: *edit_history.State, picker: *picker_state.State, entities: *registered_entities.State, vertical_navigation: *vertical_navigation.State, input_limit_rejection: *input_limit_rejection.State, /// Stages an exact replacement and records the entities it supersedes /// without mutating composer state. pub fn prepare( self: State, alloc: Allocator, text: []const u8, ) Allocator.Error!Prepared { var prepared: Prepared = .{ .dropped_entities = .{ .pasted_blocks = self.entities.pasted_blocks.items.len, .image_tokens = self.entities.image_tokens.items.len, .skill_tokens = self.entities.skill_tokens.items.len, }, }; errdefer prepared.deinit(alloc); try prepared.input.appendSlice(alloc, text); return prepared; } pub fn replace( self: State, alloc: Allocator, text: []const u8, ) Allocator.Error!void { var prepared_replacement = try self.prepare(alloc, text); defer prepared_replacement.deinit(alloc); self.commit(alloc, &prepared_replacement); } /// Swaps in staged text and resets metadata without further allocation. /// On return, `prepared_replacement` owns the displaced editor buffer. pub fn commit( self: State, alloc: Allocator, prepared_replacement: *Prepared, ) void { self.vertical_navigation.reset(); input_reset.discardSelectionWithTrace(self.edit, "input_replaced"); self.picker.clearModelPickerFlow(); traceDroppedEntities(prepared_replacement.dropped_entities); pasted_blocks.clearBlocks(alloc, &self.entities.pasted_blocks); self.entities.clearImageAndSkillTokens(alloc); self.edit.swapInput(&prepared_replacement.input); self.history.reset(alloc); self.picker.resetInlinePickerEpisode(); self.picker.model_completion_index = 0; self.picker.model_completion_window_start = 0; self.picker.resetFilePickerIndex(); self.input_limit_rejection.* = input_limit_rejection.clear(); }}; fn traceDroppedEntities(entities: DroppedEntities) void { if (entities.pasted_blocks > 0) { debug_trace.logf( "input", "pasted blocks dropped count={d} reason=input_replaced", .{entities.pasted_blocks}, ); } if (entities.image_tokens > 0) { debug_trace.logf( "input", "image tokens dropped count={d} reason=input_replaced", .{entities.image_tokens}, ); } if (entities.skill_tokens > 0) { debug_trace.logf( "input", "skill tokens dropped count={d} reason=input_replaced", .{entities.skill_tokens}, ); }} const Fixture = struct { edit: editor_state.State = .{}, history: edit_history.State = .{}, picker: picker_state.State = .{}, entities: registered_entities.State = .{}, vertical_navigation: vertical_navigation.State = .{}, input_limit_rejection: input_limit_rejection.State = .{}, fn deinit(self: *Fixture, alloc: Allocator) void { self.edit.deinit(alloc); self.history.deinit(alloc); self.picker.deinit(alloc); self.entities.deinit(alloc); } fn state(self: *Fixture) State { return .{ .edit = &self.edit, .history = &self.history, .picker = &self.picker, .entities = &self.entities, .vertical_navigation = &self.vertical_navigation, .input_limit_rejection = &self.input_limit_rejection, }; } fn seed(self: *Fixture, alloc: Allocator) !void { try self.edit.setText(alloc, "old draft"); _ = self.edit.beginSelection(0); _ = self.edit.extendSelection(self.edit.input.items.len); var prepared = try self.history.prepare( alloc, 0, "", "old draft", 0, "old draft".len, ); defer prepared.deinit(alloc); self.history.commit(alloc, &prepared); try self.picker.beginModelPickerFlow( alloc, "provider/model", 2, true, .effort, ); self.picker.slash_completion_index = 4; self.picker.slash_completion_window_start = 2; self.picker.dismissInlinePicker(.file); self.picker.model_completion_index = 5; self.picker.model_completion_window_start = 3; self.picker.file_completion_index = 6; self.picker.file_completion_window_start = 4; { const pasted_backing = try alloc.dupe(u8, "pasted backing"); errdefer alloc.free(pasted_backing); try self.entities.pasted_blocks.append(alloc, .{ .id = 7, .text = pasted_backing, .line_count = 1, }); } try self.entities.image_tokens.append(alloc, .{ .id = 8, .span = .{ .raw_start = 0, .raw_end = 0 }, }); { const skill_name = try alloc.dupe(u8, "review"); errdefer alloc.free(skill_name); const skill_path = try alloc.dupe(u8, "/tmp/review/SKILL.md"); errdefer alloc.free(skill_path); try self.entities.skill_tokens.append(alloc, .{ .raw_start = 0, .raw_end = 0, .name = skill_name, .path = skill_path, }); } self.vertical_navigation.preferred_column = 9; self.input_limit_rejection = input_limit_rejection.begin( .{}, .composer, ).next; }}; test "whole composer replacement owns semantic cleanup and cursor state" { const alloc = std.testing.allocator; var fixture: Fixture = .{}; defer fixture.deinit(alloc); try fixture.seed(alloc); const replacement = "next\nh\xc3\xa9"; try fixture.state().replace(alloc, replacement); try std.testing.expectEqualStrings(replacement, fixture.edit.input.items); try std.testing.expectEqual(replacement.len, fixture.edit.cursor); try std.testing.expect(fixture.edit.selectionRange() == null); try std.testing.expect(fixture.history.peekUndo() == null); try std.testing.expect(fixture.history.peekRedo() == null); try std.testing.expect(!fixture.picker.hasPendingModelPickerSelection()); try std.testing.expectEqual(@as(usize, 0), fixture.picker.slash_completion_index); try std.testing.expectEqual(@as(usize, 0), fixture.picker.slash_completion_window_start); try std.testing.expect(!fixture.picker.isInlinePickerDismissed(.file)); try std.testing.expectEqual(@as(usize, 0), fixture.picker.model_completion_index); try std.testing.expectEqual(@as(usize, 0), fixture.picker.model_completion_window_start); try std.testing.expectEqual(@as(usize, 0), fixture.picker.file_completion_index); try std.testing.expectEqual(@as(usize, 0), fixture.picker.file_completion_window_start); try std.testing.expectEqual(@as(usize, 0), fixture.entities.pasted_blocks.items.len); try std.testing.expectEqual(@as(usize, 0), fixture.entities.image_tokens.items.len); try std.testing.expectEqual(@as(usize, 0), fixture.entities.skill_tokens.items.len); try std.testing.expectEqual( @as(?usize, null), fixture.vertical_navigation.preferredColumn(), ); try std.testing.expect(fixture.input_limit_rejection.owner == null);} test "whole composer replacement preserves all state when staging fails" { const alloc = std.testing.allocator; var fixture: Fixture = .{}; defer fixture.deinit(alloc); try fixture.seed(alloc); var failing = std.testing.FailingAllocator.init( alloc, .{ .fail_index = 0 }, ); try std.testing.expectError( error.OutOfMemory, fixture.state().replace(failing.allocator(), "replacement"), ); try std.testing.expectEqualStrings("old draft", fixture.edit.input.items); try std.testing.expectEqual(@as(usize, "old draft".len), fixture.edit.cursor); try std.testing.expectEqual( editor_state.SelectionRange{ .start = 0, .end = "old draft".len }, fixture.edit.selectionRange().?, ); try std.testing.expect(fixture.history.peekUndo() != null); try std.testing.expect(fixture.picker.hasPendingModelPickerSelection()); try std.testing.expectEqual(@as(usize, 4), fixture.picker.slash_completion_index); try std.testing.expectEqual(@as(usize, 2), fixture.picker.slash_completion_window_start); try std.testing.expect(fixture.picker.isInlinePickerDismissed(.file)); try std.testing.expectEqual(@as(usize, 5), fixture.picker.model_completion_index); try std.testing.expectEqual(@as(usize, 3), fixture.picker.model_completion_window_start); try std.testing.expectEqual(@as(usize, 6), fixture.picker.file_completion_index); try std.testing.expectEqual(@as(usize, 4), fixture.picker.file_completion_window_start); try std.testing.expectEqual(@as(usize, 1), fixture.entities.pasted_blocks.items.len); try std.testing.expectEqual(@as(usize, 1), fixture.entities.image_tokens.items.len); try std.testing.expectEqual(@as(usize, 1), fixture.entities.skill_tokens.items.len); try std.testing.expectEqual( @as(?usize, 9), fixture.vertical_navigation.preferredColumn(), ); try std.testing.expectEqual(.composer, fixture.input_limit_rejection.owner.?);} test "prepared replacement traces entities cleared by its caller before commit" { const alloc = std.testing.allocator; var fixture: Fixture = .{}; defer fixture.deinit(alloc); try fixture.seed(alloc); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(root); const trace_path = try std.fs.path.join(alloc, &.{ root, "trace.log" }); defer alloc.free(trace_path); debug_trace.resetForTest(); defer debug_trace.resetForTest(); try debug_trace.configureForTestWithScopes(alloc, trace_path, "input"); var prepared = try fixture.state().prepare(alloc, "replacement"); defer prepared.deinit(alloc); pasted_blocks.clearBlocks(alloc, &fixture.entities.pasted_blocks); fixture.entities.clearImageAndSkillTokens(alloc); fixture.state().commit(alloc, &prepared); const zio = io_mod.getIo(); var trace_file = try std.Io.Dir.openFileAbsolute(zio, trace_path, .{}); defer trace_file.close(zio); const trace = try io_mod.readFileToEnd(alloc, &trace_file, 8192); defer alloc.free(trace); try std.testing.expect(std.mem.find(u8, trace, "pasted blocks dropped count=1 reason=input_replaced") != null); try std.testing.expect(std.mem.find(u8, trace, "image tokens dropped count=1 reason=input_replaced") != null); try std.testing.expect(std.mem.find(u8, trace, "skill tokens dropped count=1 reason=input_replaced") != null);}
const std = @import("std");
const debug_trace = @import("../shared/debug_trace.zig");
const edit_history = @import("edit_history.zig");
const editor_state = @import("editor_state.zig");
const input_limit_rejection = @import("input_limit_rejection.zig");
const input_reset = @import("input_reset.zig");
const io_mod = @import("../shared/io.zig");
const pasted_blocks = @import("pasted_blocks.zig");
const picker_state = @import("picker_state.zig");
const registered_entities = @import("registered_entities.zig");
const vertical_navigation = @import("vertical_navigation.zig");
const Allocator = std.mem.Allocator;
/// Owns staged replacement bytes before commit and the displaced editor buffer
/// afterward. Always call `deinit` with the allocator used by `prepare`.
pub const Prepared = struct {
input: std.ArrayList(u8) = .empty,
dropped_entities: DroppedEntities = .{},
pub fn deinit(self: *Prepared, alloc: Allocator) void {
self.input.deinit(alloc);
self.* = .{};
}
};
const DroppedEntities = struct {
pasted_blocks: usize = 0,
image_tokens: usize = 0,
skill_tokens: usize = 0,
};
/// Borrows the Core state changed by one whole-composer text replacement.
/// The containing input runtime retains ownership of every pointer and allocation.
pub const State = struct {
edit: *editor_state.State,
history: *edit_history.State,
picker: *picker_state.State,
entities: *registered_entities.State,
vertical_navigation: *vertical_navigation.State,
input_limit_rejection: *input_limit_rejection.State,
/// Stages an exact replacement and records the entities it supersedes
/// without mutating composer state.
pub fn prepare(
self: State,
alloc: Allocator,
text: []const u8,
) Allocator.Error!Prepared {
var prepared: Prepared = .{
.dropped_entities = .{
.pasted_blocks = self.entities.pasted_blocks.items.len,
.image_tokens = self.entities.image_tokens.items.len,
.skill_tokens = self.entities.skill_tokens.items.len,
},
};
errdefer prepared.deinit(alloc);
try prepared.input.appendSlice(alloc, text);
return prepared;
}
pub fn replace(
self: State,
alloc: Allocator,
text: []const u8,
) Allocator.Error!void {
var prepared_replacement = try self.prepare(alloc, text);
defer prepared_replacement.deinit(alloc);
self.commit(alloc, &prepared_replacement);
}
/// Swaps in staged text and resets metadata without further allocation.
/// On return, `prepared_replacement` owns the displaced editor buffer.
pub fn commit(
self: State,
alloc: Allocator,
prepared_replacement: *Prepared,
) void {
self.vertical_navigation.reset();
input_reset.discardSelectionWithTrace(self.edit, "input_replaced");
self.picker.clearModelPickerFlow();
traceDroppedEntities(prepared_replacement.dropped_entities);
pasted_blocks.clearBlocks(alloc, &self.entities.pasted_blocks);
self.entities.clearImageAndSkillTokens(alloc);
self.edit.swapInput(&prepared_replacement.input);
self.history.reset(alloc);
self.picker.resetInlinePickerEpisode();
self.picker.model_completion_index = 0;
self.picker.model_completion_window_start = 0;
self.picker.resetFilePickerIndex();
self.input_limit_rejection.* = input_limit_rejection.clear();
}
};
fn traceDroppedEntities(entities: DroppedEntities) void {
if (entities.pasted_blocks > 0) {
debug_trace.logf(
"input",
"pasted blocks dropped count={d} reason=input_replaced",
.{entities.pasted_blocks},
);
}
if (entities.image_tokens > 0) {
debug_trace.logf(
"input",
"image tokens dropped count={d} reason=input_replaced",
.{entities.image_tokens},
);
}
if (entities.skill_tokens > 0) {
debug_trace.logf(
"input",
"skill tokens dropped count={d} reason=input_replaced",
.{entities.skill_tokens},
);
}
}
const Fixture = struct {
edit: editor_state.State = .{},
history: edit_history.State = .{},
picker: picker_state.State = .{},
entities: registered_entities.State = .{},
vertical_navigation: vertical_navigation.State = .{},
input_limit_rejection: input_limit_rejection.State = .{},
fn deinit(self: *Fixture, alloc: Allocator) void {
self.edit.deinit(alloc);
self.history.deinit(alloc);
self.picker.deinit(alloc);
self.entities.deinit(alloc);
}
fn state(self: *Fixture) State {
return .{
.edit = &self.edit,
.history = &self.history,
.picker = &self.picker,
.entities = &self.entities,
.vertical_navigation = &self.vertical_navigation,
.input_limit_rejection = &self.input_limit_rejection,
};
}
fn seed(self: *Fixture, alloc: Allocator) !void {
try self.edit.setText(alloc, "old draft");
_ = self.edit.beginSelection(0);
_ = self.edit.extendSelection(self.edit.input.items.len);
var prepared = try self.history.prepare(
alloc,
0,
"",
"old draft",
0,
"old draft".len,
);
defer prepared.deinit(alloc);
self.history.commit(alloc, &prepared);
try self.picker.beginModelPickerFlow(
alloc,
"provider/model",
2,
true,
.effort,
);
self.picker.slash_completion_index = 4;
self.picker.slash_completion_window_start = 2;
self.picker.dismissInlinePicker(.file);
self.picker.model_completion_index = 5;
self.picker.model_completion_window_start = 3;
self.picker.file_completion_index = 6;
self.picker.file_completion_window_start = 4;
{
const pasted_backing = try alloc.dupe(u8, "pasted backing");
errdefer alloc.free(pasted_backing);
try self.entities.pasted_blocks.append(alloc, .{
.id = 7,
.text = pasted_backing,
.line_count = 1,
});
}
try self.entities.image_tokens.append(alloc, .{
.id = 8,
.span = .{ .raw_start = 0, .raw_end = 0 },
});
{
const skill_name = try alloc.dupe(u8, "review");
errdefer alloc.free(skill_name);
const skill_path = try alloc.dupe(u8, "/tmp/review/SKILL.md");
errdefer alloc.free(skill_path);
try self.entities.skill_tokens.append(alloc, .{
.raw_start = 0,
.raw_end = 0,
.name = skill_name,
.path = skill_path,
});
}
self.vertical_navigation.preferred_column = 9;
self.input_limit_rejection = input_limit_rejection.begin(
.{},
.composer,
).next;
}
};
test "whole composer replacement owns semantic cleanup and cursor state" {
const alloc = std.testing.allocator;
var fixture: Fixture = .{};
defer fixture.deinit(alloc);
try fixture.seed(alloc);
const replacement = "next\nh\xc3\xa9";
try fixture.state().replace(alloc, replacement);
try std.testing.expectEqualStrings(replacement, fixture.edit.input.items);
try std.testing.expectEqual(replacement.len, fixture.edit.cursor);
try std.testing.expect(fixture.edit.selectionRange() == null);
try std.testing.expect(fixture.history.peekUndo() == null);
try std.testing.expect(fixture.history.peekRedo() == null);
try std.testing.expect(!fixture.picker.hasPendingModelPickerSelection());
try std.testing.expectEqual(@as(usize, 0), fixture.picker.slash_completion_index);
try std.testing.expectEqual(@as(usize, 0), fixture.picker.slash_completion_window_start);
try std.testing.expect(!fixture.picker.isInlinePickerDismissed(.file));
try std.testing.expectEqual(@as(usize, 0), fixture.picker.model_completion_index);
try std.testing.expectEqual(@as(usize, 0), fixture.picker.model_completion_window_start);
try std.testing.expectEqual(@as(usize, 0), fixture.picker.file_completion_index);
try std.testing.expectEqual(@as(usize, 0), fixture.picker.file_completion_window_start);
try std.testing.expectEqual(@as(usize, 0), fixture.entities.pasted_blocks.items.len);
try std.testing.expectEqual(@as(usize, 0), fixture.entities.image_tokens.items.len);
try std.testing.expectEqual(@as(usize, 0), fixture.entities.skill_tokens.items.len);
try std.testing.expectEqual(
@as(?usize, null),
fixture.vertical_navigation.preferredColumn(),
);
try std.testing.expect(fixture.input_limit_rejection.owner == null);
}
test "whole composer replacement preserves all state when staging fails" {
const alloc = std.testing.allocator;
var fixture: Fixture = .{};
defer fixture.deinit(alloc);
try fixture.seed(alloc);
var failing = std.testing.FailingAllocator.init(
alloc,
.{ .fail_index = 0 },
);
try std.testing.expectError(
error.OutOfMemory,
fixture.state().replace(failing.allocator(), "replacement"),
);
try std.testing.expectEqualStrings("old draft", fixture.edit.input.items);
try std.testing.expectEqual(@as(usize, "old draft".len), fixture.edit.cursor);
try std.testing.expectEqual(
editor_state.SelectionRange{ .start = 0, .end = "old draft".len },
fixture.edit.selectionRange().?,
);
try std.testing.expect(fixture.history.peekUndo() != null);
try std.testing.expect(fixture.picker.hasPendingModelPickerSelection());
try std.testing.expectEqual(@as(usize, 4), fixture.picker.slash_completion_index);
try std.testing.expectEqual(@as(usize, 2), fixture.picker.slash_completion_window_start);
try std.testing.expect(fixture.picker.isInlinePickerDismissed(.file));
try std.testing.expectEqual(@as(usize, 5), fixture.picker.model_completion_index);
try std.testing.expectEqual(@as(usize, 3), fixture.picker.model_completion_window_start);
try std.testing.expectEqual(@as(usize, 6), fixture.picker.file_completion_index);
try std.testing.expectEqual(@as(usize, 4), fixture.picker.file_completion_window_start);
try std.testing.expectEqual(@as(usize, 1), fixture.entities.pasted_blocks.items.len);
try std.testing.expectEqual(@as(usize, 1), fixture.entities.image_tokens.items.len);
try std.testing.expectEqual(@as(usize, 1), fixture.entities.skill_tokens.items.len);
try std.testing.expectEqual(
@as(?usize, 9),
fixture.vertical_navigation.preferredColumn(),
);
try std.testing.expectEqual(.composer, fixture.input_limit_rejection.owner.?);
}
test "prepared replacement traces entities cleared by its caller before commit" {
const alloc = std.testing.allocator;
var fixture: Fixture = .{};
defer fixture.deinit(alloc);
try fixture.seed(alloc);
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, ".");
defer alloc.free(root);
const trace_path = try std.fs.path.join(alloc, &.{ root, "trace.log" });
defer alloc.free(trace_path);
debug_trace.resetForTest();
defer debug_trace.resetForTest();
try debug_trace.configureForTestWithScopes(alloc, trace_path, "input");
var prepared = try fixture.state().prepare(alloc, "replacement");
defer prepared.deinit(alloc);
pasted_blocks.clearBlocks(alloc, &fixture.entities.pasted_blocks);
fixture.entities.clearImageAndSkillTokens(alloc);
fixture.state().commit(alloc, &prepared);
const zio = io_mod.getIo();
var trace_file = try std.Io.Dir.openFileAbsolute(zio, trace_path, .{});
defer trace_file.close(zio);
const trace = try io_mod.readFileToEnd(alloc, &trace_file, 8192);
defer alloc.free(trace);
try std.testing.expect(std.mem.find(u8, trace, "pasted blocks dropped count=1 reason=input_replaced") != null);
try std.testing.expect(std.mem.find(u8, trace, "image tokens dropped count=1 reason=input_replaced") != null);
try std.testing.expect(std.mem.find(u8, trace, "skill tokens dropped count=1 reason=input_replaced") != null);
}