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
6 changes: 6 additions & 0 deletions TUI/catalog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub const items = [_]Item{
.{ .name = "/plan", .desc = "Toggle plan mode" },
.{ .name = "/theme", .desc = "Switch color theme", .aliases = &.{"/t"} },
.{ .name = "/goal", .desc = "Set a standing objective" },
.{ .name = "/schedule", .desc = "Fire a prompt later (30s/5m/2h)" },
.{ .name = "/adapter", .desc = "Channel-worker send / inbox" },
.{ .name = "/thinking", .desc = "Show live reasoning" },
.{ .name = "/fast", .desc = "Priority service tier" },
.{ .name = "/ultracode", .desc = "Persistent workflow mode", .aliases = &.{"/ult"} },
Expand All @@ -41,6 +43,8 @@ pub const items = [_]Item{
.{ .name = "/jump", .desc = "Jump to a previous turn" },
.{ .name = "/copy", .desc = "Copy the last reply to the clipboard" },
.{ .name = "/btw", .desc = "Queue an aside without interrupting" },
.{ .name = "/tell", .desc = "Message a running graff (/tell all broadcasts)" },
.{ .name = "/peek", .desc = "See what a live session is doing" },
.{ .name = "/vim-mode", .desc = "Vim keys in the scrollback", .aliases = &.{"/vim"} },
.{ .name = "/help", .desc = "List commands" },
.{ .name = "/doctor", .desc = "Health check" },
Expand Down Expand Up @@ -99,6 +103,8 @@ test "filter: slash prefix and alias" {
try std.testing.expect(lookup("/debug") != null);
try std.testing.expect(lookup("/cache") != null);
try std.testing.expect(lookup("/cost") != null);
try std.testing.expect(lookup("/tell") != null);
try std.testing.expect(lookup("/peek") != null);
try std.testing.expect(lookup("/not-a-cmd") == null);
}

Expand Down
3 changes: 3 additions & 0 deletions TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const app = @import("app.zig");
const bgop = @import("bgop.zig");
const catalog = @import("catalog.zig");
const engine = @import("engine.zig");
const peer_cmd = @import("peer_cmd.zig");
const meters = @import("meters.zig");
const theme_mod = @import("theme.zig");
const turn = @import("turn.zig");
Expand Down Expand Up @@ -188,6 +189,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
self.pushFmt(.system, "vim scrollback: {s}", .{onOff(self.vim_mode)}) catch {};
} else if (std.mem.eql(u8, canon, "/copy")) {
copyLastReply(self);
} else if (std.mem.eql(u8, canon, "/tell") or std.mem.eql(u8, canon, "/peek")) {
peer_cmd.run(self, canon, arg);
} else if (std.mem.eql(u8, canon, "/btw")) {
if (arg.len == 0) {
self.push(.system, "usage: /btw <aside> — queue a note without interrupting") catch {};
Expand Down
7 changes: 7 additions & 0 deletions TUI/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ pub var g_raw: ?*StreamBuf = null;
pub const IdleWakeFn = *const fn (turn_ctx: ?*anyopaque, buf: []u8) ?[]const u8;
pub var g_idle_wake_fn: ?IdleWakeFn = null;

/// Run a peer-talk slash (`/tell`, `/peek`) on the live agent. The TUI does
/// not post to the room itself — the host implements this with the existing
/// mailbox (`tellCommand` / `peekCommand`). Caller frees the returned text.
pub const PeerFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, line: []const u8) ?[]const u8;
pub var g_peer_fn: ?PeerFn = null;

/// A background engine op: `/compact`, `!cmd`, or the @-file list. Same
/// thread + done-flag contract as Job, so the render+input loop keeps painting
/// and Esc keeps reaching keys.handle while the engine works (#533). Every
Expand Down Expand Up @@ -244,6 +250,7 @@ pub const RunOpts = struct {
compact_fn: ?CompactFn = null,
history_fn: ?HistoryFn = null,
idle_wake_fn: ?IdleWakeFn = null,
peer_fn: ?PeerFn = null,
};

pub var g_turn_fn: ?TurnFn = null;
Expand Down
8 changes: 5 additions & 3 deletions TUI/overlaypane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ const help_body =
\\ /jump jump to a previous turn
\\ /copy copy the last reply
\\ /btw queue an aside mid-turn
\\ /tell message a live graff (/tell all broadcasts)
\\ /peek see what a live session is doing
\\ /vim-mode vim keys in the scrollback
;

Expand All @@ -270,11 +272,11 @@ test "help overlay names the advertised pager commands" {
defer arena.deinit();
const text = try overlay(&m, arena.allocator(), 80);
for ([_][]const u8{
"/quit", "/help", "/new", "/home", "/model", "/settings", "/usage", "/debug", "/cache", "/plan", "/always-approve",
"Shift+Tab", "PgUp", "PgDn",
"/quit", "/help", "/new", "/home", "/model", "/settings", "/usage", "/debug", "/cache", "/plan", "/always-approve",
"/tell", "/peek", "Shift+Tab", "PgUp", "PgDn",
"←",
"→",
"Tab", "Enter", "Esc",
"Tab", "Enter", "Esc",
}) |name| {
try testing.expect(std.mem.indexOf(u8, text, name) != null);
}
Expand Down
30 changes: 30 additions & 0 deletions TUI/peer_cmd.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! TUI `/tell` and `/peek`: thin client of the engine mailbox.
//! Posting lives in `src/peer_channel.zig`; this file only routes the slash.

const std = @import("std");

const app = @import("app.zig");
const engine = @import("engine.zig");
const Model = app.Model;

pub const tell_usage = "usage: /tell <session|all> <text> — all reaches every live session on this device, any folder";
pub const peek_usage = "usage: /peek <session> — what a live session is doing right now";
pub const offline_note = "peer talk needs a live session (offline)";

pub fn run(self: *Model, canon: []const u8, arg: []const u8) void {
if (engine.g_peer_fn) |f| {
var buf: [2048]u8 = undefined;
const line = if (arg.len == 0) canon else (std.fmt.bufPrint(&buf, "{s} {s}", .{ canon, arg }) catch canon);
if (f(engine.g_turn_ctx, self.alloc, line)) |text| {
defer self.alloc.free(text);
const trimmed = std.mem.trimEnd(u8, text, " \t\r\n");
if (trimmed.len > 0) self.push(.system, trimmed) catch {};
}
return;
}
if (arg.len == 0) {
self.push(.system, if (std.mem.eql(u8, canon, "/peek")) peek_usage else tell_usage) catch {};
} else {
self.push(.system, offline_note) catch {};
}
}
101 changes: 101 additions & 0 deletions TUI/peer_tests.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! #563 Slice B: TUI `/tell` / `/peek` are catalogued and route through the
//! engine. Drive the pager with `sim.Term` — no PTY, no Ghostty window.

const std = @import("std");

const app = @import("app.zig");
const catalog = @import("catalog.zig");
const dispatch = @import("dispatch.zig");
const engine = @import("engine.zig");
const peer_cmd = @import("peer_cmd.zig");
const sim = @import("sim.zig");

test "catalog lists /tell and /peek" {
try std.testing.expect(catalog.lookup("/tell") != null);
try std.testing.expect(catalog.lookup("/peek") != null);
try std.testing.expectEqualStrings("/tell", catalog.lookup("/tell").?.name);
try std.testing.expectEqualStrings("/peek", catalog.lookup("/peek").?.name);
}

test "bare /tell and /peek print usage when the engine is offline" {
var m: app.Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = dispatch.applyLine(&m, "/tell");
try std.testing.expect(std.mem.indexOf(u8, m.history.items[m.history.items.len - 1].text, "usage: /tell") != null);
_ = dispatch.applyLine(&m, "/peek");
try std.testing.expect(std.mem.indexOf(u8, m.history.items[m.history.items.len - 1].text, "usage: /peek") != null);
}

test "/tell from the pager reaches the engine and shows the posted line" {
const Seen = struct {
var line: []u8 = &.{};
var room: std.ArrayList(u8) = .empty;
fn f(_: ?*anyopaque, gpa: std.mem.Allocator, raw: []const u8) ?[]const u8 {
if (line.len > 0) gpa.free(line);
line = gpa.dupe(u8, raw) catch return null;
room.appendSlice(gpa, raw) catch {};
room.append(gpa, '\n') catch {};
return gpa.dupe(u8, "⇢ posted to the device-wide room") catch null;
}
};
Seen.line = &.{};
Seen.room = .empty;
engine.g_peer_fn = Seen.f;
defer {
engine.g_peer_fn = null;
if (Seen.line.len > 0) std.testing.allocator.free(Seen.line);
Seen.room.deinit(std.testing.allocator);
}

var term: sim.Term = undefined;
term.init(std.testing.allocator, 80, 24);
defer term.deinit();
_ = term.typeText("/tell all hold the tree");
_ = term.enter();

try std.testing.expectEqualStrings("/tell all hold the tree", Seen.line);
try std.testing.expect(std.mem.indexOf(u8, Seen.room.items, "hold the tree") != null);
try std.testing.expect(term.model.history.items.len > 0);
const last = term.model.history.items[term.model.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, last, "posted to the device-wide room") != null);

const vis = try term.screen();
defer std.testing.allocator.free(vis);
try std.testing.expect(std.mem.indexOf(u8, vis, "posted to the device-wide room") != null);
}

test "/peek from the pager reaches the engine" {
const Seen = struct {
var line: []u8 = &.{};
fn f(_: ?*anyopaque, gpa: std.mem.Allocator, raw: []const u8) ?[]const u8 {
if (line.len > 0) gpa.free(line);
line = gpa.dupe(u8, raw) catch return null;
return gpa.dupe(u8, "⚡ other · pid 9 · goal: refactor") catch null;
}
};
Seen.line = &.{};
engine.g_peer_fn = Seen.f;
defer {
engine.g_peer_fn = null;
if (Seen.line.len > 0) std.testing.allocator.free(Seen.line);
}

var term: sim.Term = undefined;
term.init(std.testing.allocator, 80, 24);
defer term.deinit();
_ = term.typeText("/peek other");
_ = term.enter();
try std.testing.expectEqualStrings("/peek other", Seen.line);
const last = term.model.history.items[term.model.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, last, "goal: refactor") != null);
}

test "offline /tell with a payload explains instead of posting" {
engine.g_peer_fn = null;
var m: app.Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = dispatch.applyLine(&m, "/tell all hi");
try std.testing.expectEqualStrings(peer_cmd.offline_note, m.history.items[m.history.items.len - 1].text);
}
3 changes: 3 additions & 0 deletions TUI/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub const CompactOut = engine.CompactOut;
pub const CompactFn = engine.CompactFn;
pub const HistoryOp = engine.HistoryOp;
pub const HistoryFn = engine.HistoryFn;
pub const PeerFn = engine.PeerFn;
pub const RunOpts = run_mod.RunOpts;
pub const run = run_mod.run;
pub const restore = @import("restore.zig");
Expand All @@ -54,6 +55,8 @@ test {
_ = app;
_ = @import("app_tests.zig");
_ = @import("dispatch.zig");
_ = @import("peer_cmd.zig");
_ = @import("peer_tests.zig");
_ = @import("prompt_history.zig");
_ = @import("meters.zig");
_ = @import("key.zig");
Expand Down
12 changes: 2 additions & 10 deletions TUI/run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub fn run(
engine.g_compact_fn = opts.compact_fn;
engine.g_history_fn = opts.history_fn;
engine.g_idle_wake_fn = opts.idle_wake_fn;
engine.g_peer_fn = opts.peer_fn;
engine.g_model_name = opts.model_name;
engine.g_model_provider = opts.model_provider;
engine.g_model_entries = opts.model_entries;
Expand Down Expand Up @@ -298,13 +299,7 @@ pub fn run(
continue;
}
esc_stall = 0;
if (pending_len == inbuf.len) {
// A stuck head has filled the whole buffer: that is a parser
// wedge, not a dead tty. Drop it rather than letting the
// zero-length read below masquerade as a hangup and kill the
// TUI mid-session (#517).
pending_len = 0;
}
pending_len = stall.clearFullWedge(pending_len, inbuf.len);
var filled = pending_len;
const got = tty.readStdin(inbuf[filled..]);
pacing.reads += 1;
Expand Down Expand Up @@ -505,9 +500,6 @@ test "run loop enables click+hover tracking and bracketed paste" {
try std.testing.expect(std.mem.indexOf(u8, src, &kitty_on) != null);
try std.testing.expect(std.mem.indexOf(u8, src, &wrap_off) != null);
try std.testing.expect(std.mem.indexOf(u8, src, "a=d,d=A") != null);
// #517: a buffer-filling parser wedge must be cleared before the read,
// or the zero-length read reads as a hangup and kills the TUI.
try std.testing.expect(std.mem.indexOf(u8, src, "pending_len == inbuf.len") != null);
// The idle paste sweep must DISCARD whatever was stuck mid-sequence before
// the stall path below can see it. Leaving it there let a lone pending ESC
// become the Escape KEY the instant `in_paste` cleared, cancelling a live
Expand Down
13 changes: 13 additions & 0 deletions TUI/run_stall.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ pub fn armExpired(now_ms: u64, arm_ms: u64) bool {
return now_ms -| arm_ms > arm_window_ms;
}

/// A stuck CSI/OSC head that filled the read buffer is a parser wedge, not a
/// hangup. Drop it so the next `read` is not a zero-length "TTY gone" (#517).
pub fn clearFullWedge(pending_len: usize, buf_len: usize) usize {
return if (pending_len == buf_len) 0 else pending_len;
}

/// Is this read nothing but complete SGR mouse reports?
///
/// ?1003h is on by default for image-chip hover, and a pointer merely RESTING
Expand Down Expand Up @@ -202,3 +208,10 @@ test "a lone ESC inside a latched paste is the escape hatch, not a 2s wait" {
// Outside a paste nothing moved: #94's 2-stall Escape still fires.
try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 2, .{}));
}

test "a buffer-filling parser wedge is dropped, not treated as hangup (#517)" {
try std.testing.expectEqual(@as(usize, 0), clearFullWedge(4096, 4096));
try std.testing.expectEqual(@as(usize, 12), clearFullWedge(12, 4096));
try std.testing.expectEqual(@as(usize, 0), clearFullWedge(0, 4096));
try std.testing.expectEqual(@as(usize, 1), clearFullWedge(1, 2));
}
29 changes: 21 additions & 8 deletions TUI/tty.zig
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ pub const RawState = if (is_windows) struct {
input_cp: u32 = 0,
} else std.posix.termios;

/// Clear the line-discipline bits that steal keys the TUI owns (#523).
/// Extracted so tests can mutate a termios-shaped value without a real tty.
pub fn surrenderLineDiscipline(raw: anytype) void {
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
raw.lflag.ISIG = false;
raw.lflag.IEXTEN = false; // ^V (0x16) reaches us, not the tty's lnext
raw.iflag.IXON = false; // ^S/^Q are keys, not XOFF/XON flow control
}

pub fn enterRaw() ?RawState {
if (is_windows) {
const h = w.GetStdHandle(w.STD_OUTPUT_HANDLE);
Expand All @@ -67,11 +77,7 @@ pub fn enterRaw() ?RawState {
const fd = std.posix.STDIN_FILENO;
const orig = std.posix.tcgetattr(fd) catch return null;
var raw = orig;
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
raw.lflag.ISIG = false;
raw.lflag.IEXTEN = false; // ^V (0x16) reaches us, not the tty's lnext (#523)
raw.iflag.IXON = false; // ^S/^Q are keys, not XOFF/XON flow control (#523)
surrenderLineDiscipline(&raw);
raw.cc[@intFromEnum(std.posix.V.MIN)] = 0;
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
std.posix.tcsetattr(fd, .NOW, raw) catch return null;
Expand Down Expand Up @@ -100,9 +106,16 @@ pub fn readStdin(buf: []u8) usize {
}

test "raw mode surrenders ^V and ^S to the app, not the line discipline (#523)" {
const src = @embedFile("tty.zig");
try std.testing.expect(std.mem.indexOf(u8, src, "IEXTEN = false") != null);
try std.testing.expect(std.mem.indexOf(u8, src, "IXON = false") != null);
var raw = struct {
lflag: struct { ICANON: bool = true, ECHO: bool = true, ISIG: bool = true, IEXTEN: bool = true } = .{},
iflag: struct { IXON: bool = true } = .{},
}{};
surrenderLineDiscipline(&raw);
try std.testing.expect(!raw.lflag.IEXTEN);
try std.testing.expect(!raw.iflag.IXON);
try std.testing.expect(!raw.lflag.ICANON);
try std.testing.expect(!raw.lflag.ECHO);
try std.testing.expect(!raw.lflag.ISIG);
}

test "Windows enterRaw switches the console to UTF-8 and restore puts the CPs back (#607)" {
Expand Down
12 changes: 6 additions & 6 deletions docs/acp-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ and the host recipe stays [embedding.md](embedding.md). Replace those with
| `graff acp` stdio agent | Shipped. Protocol version 1. |
| Mid-turn `session/update` (thought / tool / text) | Shipped (ADR [0032](adr/0032-acp-streams-mid-turn.md), #612). |
| Provider login | `graff login` / keychain / env keys. Not ACP `authenticate`. |
| `initialize.authMethods` | **Missing.** Registry CI requires at least one method with `type: "agent"` or `type: "terminal"`. |
| `initialize.authMethods` | **Shipped.** `graff-login` / `type: "terminal"` / `args: ["login"]`. `authenticate` stays unused — the client re-spawns `graff login`. |
| `session/load`, `session/request_permission` | Not implemented. Unattended + `--yolo` is the host path. |

A stale comment on #613 said #612 was still open. It is not: streaming is on
Expand All @@ -33,8 +33,8 @@ accepts only:
- **Terminal Auth** — the client re-spawns the binary with setup args (a TUI
login), then runs the normal ACP command.

`graff login` is already Terminal Auth in product terms. Listing still fails
until `initialize` advertises it, for example:
`graff login` is already Terminal Auth in product terms. `initialize` now
advertises it:

```json
{
Expand Down Expand Up @@ -121,11 +121,11 @@ Pin `sha256` from that release's `SHA256SUMS` before the PR. Archive layout
is sometimes flat (`graff` at the tarball root) and sometimes nested
(`graff-<target>/graff`); `cmd` must match what the installer extracts.

Until `authMethods` is on the wire, this draft is documentation, not a
submission.
`authMethods` is on the wire. Pin `sha256` from a real release, then open the
registry PR in the other repo. This repo still does not fetch the catalog.

## Not this repo

- No `graff registry` command.
- No fetch of `cdn.agentclientprotocol.com`.
- No change to `src/acp.zig` on this continuation cut for listing.
- No `authenticate` implementation (Terminal Auth is out of band).
Loading