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
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 };
pub const AgentMode = enum { normal, plan, always_approve };
pub const EscArm = enum { none, clear, rewind };
pub const EntryKind = enum { user, assistant, tool, system, err, pending };
Expand Down Expand Up @@ -78,6 +78,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 @@ -120,6 +121,7 @@ pub const Model = struct {
if (self.session_name) |s| self.alloc.free(s);
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);
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
1 change: 1 addition & 0 deletions TUI/chrome.zig
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ pub fn overlay(self: *const Model, a: std.mem.Allocator, width: usize) ![]const
.image => try @import("image.zig").render(self, a, width),
.file => try @import("files.zig").render(self, a),
.jump => try jumpOverlay(self, a),
.resume_pick => try @import("resume.zig").render(self, a),
.slash => "",
};
}
Expand Down
5 changes: 4 additions & 1 deletion TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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 @@ -67,6 +67,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
12 changes: 12 additions & 0 deletions TUI/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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;

pub const Job = struct {
thread: std.Thread = undefined,
Expand All @@ -84,6 +94,8 @@ pub var g_bash_fn: ?BashFn = null;
pub var g_files_fn: ?FilesFn = null;
pub var g_copy_fn: ?CopyFn = null;
pub var g_compact_fn: ?CompactFn = null;
pub var g_sessions_fn: ?SessionsFn = null;
pub var g_resume_fn: ?ResumeFn = null;
pub var g_model_name: []const u8 = "";
pub var g_models: []const u8 = "";
pub var g_cwd: []const u8 = ".";
Expand Down
3 changes: 2 additions & 1 deletion TUI/overlays.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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 @@ -119,6 +119,7 @@ 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
181 changes: 181 additions & 0 deletions TUI/resume.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! /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 theme_mod = @import("theme.zig");
const Model = app.Model;

pub const max_rows = 256;
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);
}

pub fn render(self: *const Model, a: std.mem.Allocator) ![]const u8 {
const th = self.theme();
var rows: [max_rows]Row = undefined;
const total = filterRows(self.sessions_cache orelse "", "", &rows);
const n = filterRows(self.sessions_cache orelse "", self.overlay_filter, &rows);
var out = std.array_list.Managed(u8).init(a);
try out.appendSlice(try theme_mod.paint(a, th.accent, try std.fmt.allocPrint(a, "Resume › {s}▋", .{self.overlay_filter})));
try out.append('\n');
try out.appendSlice(try theme_mod.paint(a, th.muted, try std.fmt.allocPrint(a, "{d}/{d}", .{ n, total })));
try out.appendSlice("\n\n");
if (n == 0) {
try out.appendSlice(try theme_mod.paint(a, th.muted, "no matches — type to filter, Esc to close\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');
}
try out.append('\n');
try out.appendSlice(try theme_mod.paint(a, th.muted, "type to search · ↑↓ move · Enter resume · Esc"));
try out.append('\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();
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)) |nm| engine.g_model_name = nm;
}
}
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");
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.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);
}
3 changes: 3 additions & 0 deletions TUI/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub const ModelFn = engine.ModelFn;
pub const CancelFn = engine.CancelFn;
pub const CompactOut = engine.CompactOut;
pub const CompactFn = engine.CompactFn;
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 theme = @import("theme.zig");
Expand Down
4 changes: 4 additions & 0 deletions TUI/run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub const RunOpts = struct {
files_fn: ?engine.FilesFn = null,
copy_fn: ?engine.CopyFn = null,
compact_fn: ?engine.CompactFn = null,
sessions_fn: ?engine.SessionsFn = null,
resume_fn: ?engine.ResumeFn = null,
};

pub fn run(
Expand All @@ -56,6 +58,8 @@ pub fn run(
engine.g_files_fn = opts.files_fn;
engine.g_copy_fn = opts.copy_fn;
engine.g_compact_fn = opts.compact_fn;
engine.g_sessions_fn = opts.sessions_fn;
engine.g_resume_fn = opts.resume_fn;
engine.g_model_name = opts.model_name;
engine.g_models = opts.models;
engine.g_cwd = opts.cwd;
Expand Down
1 change: 1 addition & 0 deletions src/test_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,5 @@ test {
_ = side_question_tests;
_ = json_controls;
_ = effort_route;
_ = @import("tui_resume.zig");
}
2 changes: 2 additions & 0 deletions src/tui_launch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ pub fn run(
.files_fn = filesCb,
.copy_fn = copyCb,
.compact_fn = compactCb,
.sessions_fn = @import("tui_resume.zig").sessionsCb,
.resume_fn = @import("tui_resume.zig").resumeCb,
});
}

Expand Down
Loading
Loading