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: 3 additions & 1 deletion TUI/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const theme_mod = @import("theme.zig");

pub const Screen = enum { welcome, agent };
pub const Focus = enum { prompt, scrollback };
pub const Overlay = enum { none, palette, help, theme, model, effort, settings, rewind, slash, debug, image, file, jump };
pub const Overlay = enum { none, palette, help, theme, model, effort, settings, rewind, slash, debug, image, file, jump, resume_pick };
/// One vocabulary with the engine (#551): the Model does not keep a private
/// copy of the permission policy, it holds the engine's own enum and hands it
/// straight to the turn backend.
Expand Down Expand Up @@ -168,6 +168,7 @@ pub const Model = struct {
hist_idx: ?usize = null,
/// Newline-joined paths for the @-file picker, loaded once per session.
files_cache: ?[]const u8 = null,
sessions_cache: ?[]const u8 = null,

toast: []const u8 = "",
toast_until_ms: u64 = 0,
Expand Down Expand Up @@ -244,6 +245,7 @@ pub const Model = struct {
}
if (self.overlay_filter.len > 0) self.alloc.free(self.overlay_filter);
if (self.files_cache) |f| self.alloc.free(f);
if (self.sessions_cache) |s| self.alloc.free(s);
if (self.sel_text.len > 0) self.alloc.free(self.sel_text);
if (self.osc_pending.len > 0) self.alloc.free(self.osc_pending);
self.input.deinit();
Expand Down
1 change: 1 addition & 0 deletions TUI/catalog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub const items = [_]Item{
.{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} },
.{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} },
.{ .name = "/compact", .desc = "Engine-compact model-visible history" },
.{ .name = "/resume", .desc = "Resume a saved session", .aliases = &.{"/sessions"} },
.{ .name = "/context", .desc = "Show context-window use" },
.{ .name = "/session-info", .desc = "Session details", .aliases = &.{ "/status", "/info" } },
.{ .name = "/usage", .desc = "Token usage and cost", .aliases = &.{"/cost"} },
Expand Down
5 changes: 4 additions & 1 deletion TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
// #521: history-destroying commands must not run under a live job — the
// steer guard in promptKey only covers plain text, and the slash menu,
// palette, and steer drain all land here.
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind");
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume");
if (self.pending != null and destroys) {
self.push(.system, "a turn is still running — press Esc to cancel it first") catch {};
return .stay;
Expand All @@ -83,6 +83,9 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
self.focus = .prompt;
} else if (std.mem.eql(u8, canon, "/rewind")) {
rewind(self);
} else if (std.mem.eql(u8, canon, "/resume")) {
const res = @import("resume.zig");
if (arg.len == 0) res.open(self) else res.resumeByName(self, arg);
} else if (std.mem.eql(u8, canon, "/compact")) {
compact(self);
} else if (std.mem.eql(u8, canon, "/help")) {
Expand Down
3 changes: 2 additions & 1 deletion TUI/dispatch_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test "a model turn cannot start while /compact is rewriting the history (#533)"
try std.testing.expect(m.bg == null);
}

test "/new /compact /rewind are blocked while a job is pending (#521)" {
test "/new /compact /rewind /resume are blocked while a job is pending (#521)" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
Expand All @@ -83,6 +83,7 @@ test "/new /compact /rewind are blocked while a job is pending (#521)" {
try std.testing.expect(std.mem.indexOf(u8, m.history.items[1].text, "still running") != null);
_ = dispatch.runCommand(&m, "/rewind");
_ = dispatch.runCommand(&m, "/compact");
_ = dispatch.runCommand(&m, "/resume");
try std.testing.expectEqualStrings("hi", m.history.items[0].text);
}

Expand Down
14 changes: 14 additions & 0 deletions TUI/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ pub const CompactOut = struct {
turns: []Turn = &.{},
};
pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, history: []const Turn, out: *CompactOut) bool;
/// Newline-joined "base\ttitle\tage" rows of saved sessions, gpa-owned —
/// the /resume picker's list (same store the line REPL's /resume reads).
pub const SessionsFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator) ?[]const u8;
/// Fill `out` with a saved session's user/assistant turns and its saved
/// model name (all gpa-owned; caller frees). False when the load fails.
pub const ResumeOut = struct {
turns: []Turn = &.{},
model: []const u8 = "",
};
pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, base: []const u8, out: *ResumeOut) bool;

/// The frontend just discarded part of its transcript. The engine owns the
/// conversation the model actually sees (#551), so it has to be told: without
Expand Down Expand Up @@ -234,6 +244,8 @@ pub const RunOpts = struct {
copy_fn: ?CopyFn = null,
compact_fn: ?CompactFn = null,
history_fn: ?HistoryFn = null,
sessions_fn: ?SessionsFn = null,
resume_fn: ?ResumeFn = null,
};

pub var g_turn_fn: ?TurnFn = null;
Expand All @@ -247,6 +259,8 @@ pub var g_files_fn: ?FilesFn = null;
pub var g_copy_fn: ?CopyFn = null;
pub var g_compact_fn: ?CompactFn = null;
pub var g_history_fn: ?HistoryFn = null;
pub var g_sessions_fn: ?SessionsFn = null;
pub var g_resume_fn: ?ResumeFn = null;

/// Tell the engine the transcript was cut. Silent when nothing is wired
/// (offline TUI, unit tests).
Expand Down
6 changes: 6 additions & 0 deletions TUI/overlaypane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ fn spec(self: *const Model, a: std.mem.Allocator) !panel.Spec {
const head = try files.head(self, a);
break :blk .{ .title = head.title, .note = head.note, .footer = files.hint, .body = try files.render(self, a) };
},
.resume_pick => blk: {
const pick = @import("resume.zig");
const head = try pick.head(self, a);
break :blk .{ .title = head.title, .note = head.note, .footer = pick.hint, .body = try pick.render(self, a) };
},
.settings => .{
.title = "Settings",
.footer = "↑↓ move · click or Enter changes · Esc",
Expand Down Expand Up @@ -351,6 +356,7 @@ test "every overlay is a bordered panel with its name in the frame" {
.{ .o = .model, .name = "Model" },
.{ .o = .effort, .name = "Effort" },
.{ .o = .file, .name = "File" },
.{ .o = .resume_pick, .name = "Resume" },
.{ .o = .settings, .name = "Settings" },
.{ .o = .jump, .name = "Jump to turn" },
};
Expand Down
10 changes: 9 additions & 1 deletion TUI/overlays.zig
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub fn key(self: *Model, k: Key) Effect {
return .stay;
}
if (k == .enter) return activate(self);
if (self.overlay == .model or self.overlay == .effort or self.overlay == .file) {
if (self.overlay == .model or self.overlay == .effort or self.overlay == .file or self.overlay == .resume_pick) {
switch (k) {
.char => |c| self.typeOverlayFilter(c),
.backspace => self.backspaceOverlayFilter(),
Expand Down Expand Up @@ -169,6 +169,13 @@ pub fn rowSpan(self: *const Model) ?Span {
if (n == 0) return null;
return windowed(frame_rows, n, self.overlay_sel % n, files_mod.visible_rows);
},
.resume_pick => {
const pick = @import("resume.zig");
var rows: [pick.max_rows]pick.Row = undefined;
const n = pick.filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows);
if (n == 0) return null;
return windowed(frame_rows, n, self.overlay_sel % n, pick.visible_rows);
},
else => return null,
}
}
Expand Down Expand Up @@ -277,6 +284,7 @@ pub fn activate(self: *Model) Effect {
self.input.handle(.{ .char = ' ' });
self.focus = .prompt;
},
.resume_pick => @import("resume.zig").pick(self),
.jump => {
const total = self.userTurnCount();
const sel = if (total == 0) 0 else self.overlay_sel % total;
Expand Down
205 changes: 205 additions & 0 deletions TUI/resume.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! /resume picker: the line REPL's saved sessions (.graff/sessions), listed
//! and loaded through the engine seam so the TUI stays engine-agnostic.
//! Cache rows are "base\ttitle\tage" lines from engine.g_sessions_fn.

const std = @import("std");

const app = @import("app.zig");
const engine = @import("engine.zig");
const models = @import("models.zig");
const panel = @import("panel.zig");
const theme_mod = @import("theme.zig");
const Model = app.Model;

pub const max_rows = 256;
/// Rows the picker draws at once — overlays.rowSpan reads the same window.
pub const visible_rows = 14;

pub const Row = struct { base: []const u8, title: []const u8, age: []const u8 };

fn parseRow(line: []const u8) Row {
var it = std.mem.splitScalar(u8, line, '\t');
return .{
.base = it.next() orelse "",
.title = it.next() orelse "",
.age = it.next() orelse "",
};
}

pub fn filterRows(cache: []const u8, query: []const u8, out: []Row) usize {
var n: usize = 0;
var it = std.mem.splitScalar(u8, cache, '\n');
while (it.next()) |line| {
if (line.len == 0) continue;
const r = parseRow(line);
if (!(models.modelMatch(r.base, query) or models.modelMatch(r.title, query))) continue;
if (n >= out.len) break;
out[n] = r;
n += 1;
}
return n;
}

/// Load the saved-session list once, then open the picker.
pub fn open(self: *Model) void {
if (self.sessions_cache == null) {
if (engine.g_sessions_fn) |f| self.sessions_cache = f(engine.g_turn_ctx, self.alloc);
}
if (self.sessions_cache == null or self.sessions_cache.?.len == 0) {
self.screen = .agent; // welcome hides system rows until a user turn
self.push(.system, "no saved sessions — /save one in the line REPL first") catch {};
return;
}
self.openOverlay(.resume_pick);
}

/// The panel's top-edge slots (overlaypane.zig places them).
pub fn head(self: *const Model, a: std.mem.Allocator) !panel.Head {
var rows: [max_rows]Row = undefined;
const cache = self.sessions_cache orelse "";
const total = filterRows(cache, "", &rows);
const n = filterRows(cache, self.overlay_filter, &rows);
return .{
.title = try std.fmt.allocPrint(a, "Resume › {s}\u{258B}", .{self.overlay_filter}),
.note = try std.fmt.allocPrint(a, "{d}/{d}", .{ n, total }),
};
}

pub const hint = "type to search · ↑↓ move · click or Enter resume · Esc";

/// The ROWS only — the frame carries the rest.
pub fn render(self: *const Model, a: std.mem.Allocator) ![]const u8 {
const th = self.theme();
var rows: [max_rows]Row = undefined;
const n = filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows);
var out = std.array_list.Managed(u8).init(a);
if (n == 0) {
try out.appendSlice(try theme_mod.paint(a, th.muted, " no matches — type to filter"));
try out.append('\n');
return out.items;
}
const sel = self.overlay_sel % n;
const vis = @min(visible_rows, n);
const off = if (sel >= vis) sel - vis + 1 else 0;
var i = off;
while (i < n and i < off + vis) : (i += 1) {
const mark: []const u8 = if (i == sel) "› " else " ";
const line = if (rows[i].title.len > 0)
try std.fmt.allocPrint(a, "{s}{s} — {s} ({s})", .{ mark, rows[i].base, rows[i].title, rows[i].age })
else
try std.fmt.allocPrint(a, "{s}{s} ({s})", .{ mark, rows[i].base, rows[i].age });
try out.appendSlice(if (i == sel) try theme_mod.paint(a, th.accent, line) else try theme_mod.paint(a, th.muted, line));
try out.append('\n');
}
if (n > vis) try out.appendSlice(try panel.windowRow(a, th, off, vis, n));
return out.items;
}

/// Enter in the picker.
pub fn pick(self: *Model) void {
var rows: [max_rows]Row = undefined;
const n = filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows);
const sel = if (n == 0) 0 else self.overlay_sel % n;
self.closeOverlay(); // keeps sessions_cache, so rows[sel].base stays valid
if (n == 0) return;
resumeByName(self, rows[sel].base);
}

pub fn resumeByName(self: *Model, base: []const u8) void {
const f = engine.g_resume_fn orelse {
self.push(.system, "resume needs a live session") catch {};
return;
};
var out: engine.ResumeOut = .{};
const ok = f(engine.g_turn_ctx, self.alloc, base, &out);
defer {
for (out.turns) |t| self.alloc.free(t.text);
if (out.turns.len > 0) self.alloc.free(out.turns);
if (out.model.len > 0) self.alloc.free(out.model);
}
if (!ok) {
self.screen = .agent; // welcome hides err rows until a user turn
self.pushFmt(.err, "couldn't resume '{s}' — see /sessions in the line REPL", .{base}) catch {};
return;
}
self.clearHistory();
// The engine owns the conversation (#551): drop it so the next turn's
// adopt() seeds from the resumed transcript instead of the old session.
engine.historyChanged(.reset);
self.screen = .agent;
for (out.turns) |t| {
self.push(switch (t.role) {
.user => .user,
.assistant => .assistant,
}, t.text) catch {};
}
if (out.model.len > 0) {
if (engine.g_model_fn) |mf| {
if (mf(engine.g_turn_ctx, self.alloc, "", out.model)) |got| self.adoptModel(got);
}
}
self.pushFmt(.system, "resumed {s} · {d} turns", .{ base, out.turns.len }) catch {};
self.scroll = 0;
self.follow = true;
}

test "filterRows parses tab rows and fuzzy-matches base or title" {
const cache = "fix-login\tFix login bug\t2h ago\nspike\t\tjust now";
var rows: [8]Row = undefined;
try std.testing.expectEqual(@as(usize, 2), filterRows(cache, "", &rows));
try std.testing.expectEqual(@as(usize, 1), filterRows(cache, "login", &rows));
try std.testing.expectEqualStrings("fix-login", rows[0].base);
try std.testing.expectEqualStrings("2h ago", rows[0].age);
try std.testing.expectEqual(@as(usize, 1), filterRows(cache, "spk", &rows));
}

test "resume picker loads turns into history and notes the session" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
m.sessions_cache = try std.testing.allocator.dupe(u8, "alpha\tFirst try\t1h ago");
const Seen = struct {
var reset: bool = false;
fn hist(_: ?*anyopaque, op: engine.HistoryOp) void {
if (op == .reset) reset = true;
}
};
Seen.reset = false;
engine.g_history_fn = Seen.hist;
defer engine.g_history_fn = null;
engine.g_resume_fn = struct {
fn f(_: ?*anyopaque, gpa: std.mem.Allocator, base: []const u8, out: *engine.ResumeOut) bool {
std.testing.expectEqualStrings("alpha", base) catch return false;
const turns = gpa.alloc(engine.Turn, 2) catch return false;
turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "hi") catch return false };
turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "hello") catch return false };
out.turns = turns;
return true;
}
}.f;
defer engine.g_resume_fn = null;
m.openOverlay(.resume_pick);
pick(&m);
try std.testing.expect(Seen.reset);
try std.testing.expectEqual(app.Overlay.none, m.overlay);
try std.testing.expectEqual(@as(usize, 3), m.history.items.len); // 2 turns + note
try std.testing.expectEqual(app.EntryKind.user, m.history.items[0].kind);
try std.testing.expectEqualStrings("hello", m.history.items[1].text);
try std.testing.expect(std.mem.indexOf(u8, m.history.items[2].text, "resumed alpha") != null);
try std.testing.expectEqual(app.Screen.agent, m.screen);
}

test "resume with no saved sessions explains instead of opening an empty picker" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
engine.g_sessions_fn = struct {
fn f(_: ?*anyopaque, gpa: std.mem.Allocator) ?[]const u8 {
return gpa.dupe(u8, "") catch null;
}
}.f;
defer engine.g_sessions_fn = null;
open(&m);
try std.testing.expectEqual(app.Overlay.none, m.overlay);
try std.testing.expect(std.mem.indexOf(u8, m.history.items[0].text, "no saved sessions") != null);
}
4 changes: 4 additions & 0 deletions TUI/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub const CompactOut = engine.CompactOut;
pub const CompactFn = engine.CompactFn;
pub const HistoryOp = engine.HistoryOp;
pub const HistoryFn = engine.HistoryFn;
pub const SessionsFn = engine.SessionsFn;
pub const ResumeOut = engine.ResumeOut;
pub const ResumeFn = engine.ResumeFn;
pub const RunOpts = run_mod.RunOpts;
pub const run = run_mod.run;
pub const restore = @import("restore.zig");
Expand Down Expand Up @@ -93,6 +96,7 @@ test {
_ = @import("tty.zig");
_ = @import("restore.zig");
_ = @import("files.zig");
_ = @import("resume.zig");
_ = @import("overlays.zig");
_ = run_mod;
_ = @import("paint.zig");
Expand Down
2 changes: 2 additions & 0 deletions TUI/run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub fn run(
engine.g_copy_fn = opts.copy_fn;
engine.g_compact_fn = opts.compact_fn;
engine.g_history_fn = opts.history_fn;
engine.g_sessions_fn = opts.sessions_fn;
engine.g_resume_fn = opts.resume_fn;
engine.g_model_name = opts.model_name;
engine.g_model_provider = opts.model_provider;
engine.g_model_entries = opts.model_entries;
Expand Down
1 change: 1 addition & 0 deletions src/test_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,5 @@ test {
_ = sandbox_docker;
_ = commands_sandbox;
_ = sandbox_tests;
_ = @import("tui_resume.zig");
}
Loading
Loading