diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 1f0e867bf3..f935b8dfa8 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -12,7 +12,7 @@ "reference-bug": "the reference passes it for the wrong reason; t27b is right not to", "n/a": "outside what t27b is for (say why in the PR that sets it)" }, - "max_not_pass": 129, + "max_not_pass": 132, "entries": [ {"path": "specs/a/b_c.t27", "t27b": "pass"}, {"path": "specs/a_b/c.t27", "t27b": "pass"}, @@ -1614,6 +1614,9 @@ {"path": "specs/t27mcp/tool_schema.t27", "t27b": "codegen", "blocker": "t27b: unsupported construct FnDecl(frame or branch range) at line 0 (fn the round trip fixtures: a tuple, arrays, a boolean, nested objects: conditional branch out of range (function too large))", "reason": "unimplemented"}, {"path": "specs/t27mcp/visual_output.t27", "t27b": "pass"}, {"path": "specs/t27os/install.t27", "t27b": "pass"}, + {"path": "specs/t27os/pty_client.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, + {"path": "specs/t27os/pty_server.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, + {"path": "specs/t27os/pty_sys.t27", "t27b": "blocked", "blocker": "@extern import under the JIT", "reason": "n/a"}, {"path": "specs/t27t/t27t.t27", "t27b": "pass"}, {"path": "specs/ternary/activation_quantizer.t27", "t27b": "pass"}, {"path": "specs/ternary/bitnet_layer.t27", "t27b": "pass"}, diff --git a/specs/t27f/petal_glass.t27 b/specs/t27f/petal_glass.t27 new file mode 100644 index 0000000000..03520a4821 --- /dev/null +++ b/specs/t27f/petal_glass.t27 @@ -0,0 +1,655 @@ +// SPDX-License-Identifier: Apache-2.0 +; T27F petal glass -- the T27 OS home drawn the way the trios app draws it, every pixel from this spec. +; What it draws, layer by layer, and where each number comes from: +; G1 desktop -- what sits behind the window. Unknown to trios, so a SUGGESTED blue-violet gradient. +; G2 material -- trios TriosVisualTheme.blackGlass: backdrop blur + black tint 0.30 (EXACT). Over a +; smooth gradient a blur changes nothing, so only the tint is drawn. +; G3 wash -- UnifiedTriosGlassBackground darkWash: LinearGradient topLeading -> bottomTrailing, +; black at windowWashOpacity 0.42, clear, black at 0.42 x 0.7 (EXACT stops). +; G4 blooms -- ambientBloomOpacity 0.055; green / pink / blue circles, size max(360, 0.62 w), +; scale 1 / 0.82 / 0.72, opacity x1 / x0.8 / x0.45, blur 110 / 120 / 125, offsets +; (0.38w, 0.08h) / (0.68w, -0.12h) / (-0.16w, 0.64h) (EXACT). The blur edge profile is a +; logistic of width 0.3 x blur (DERIVED: a gaussian of sigma blur/2, approximated). +; G5 border -- 0.5 px white 0.14 at the window edge (EXACT). +; P petals -- geometry, fit 0.63, fills (idle black 0.22, hover white, open = realm colour) and the +; 1 px white outline all come from petal_menu.t27 (module T27fPetalMenu); none is copied. +; Edges are anti-aliased by a signed distance to the quad (DERIVED; the test +; the_distance_agrees_with_the_ray_cast checks it against petal_menu's own hit test). +; A assembly -- photon_trinity_canvas.zig LogoAnimation: every block starts 800 SVG units out along +; centre -> its centroid; phase 1 (progress < 0.7 of 2.5 s) off -= off * 4.5 dt and +; v = -0.4 off; phase 2 spring k 28, damping 0.86, off += v dt 60 (EXACT, at dt 1/30). +; M menu -- g_wave_transition: alpha 0 -> 1 at dt x 3, so 10 frames at 30 fps (EXACT). The panel is +; blackGlass surfaceBlack 0.46, highlightWhite 0.08, border white 0.14 (EXACT values); +; its size, 24 px lift and the card look (white 0.06 fill, 0.10 edge) are SUGGESTED. +; It holds petal_menu's 3 x 3 card slots, empty in the MVP (card_count is 0). +; S scenario -- 9 s: assembly, a cursor sweep with hover, a tap on petal 13 (MATERIYA), the menu +; fades in, holds, back, the menu fades out, the cursor leaves. +; Output: main prints 270 raw RGB24 frames of 960 x 540 to stderr, nothing else, for +; ffmpeg -f rawvideo -pix_fmt rgb24 -s 960x540 -r 30 -i - out.mp4 +; Recipe as in why_card.t27: t27c gen this file into a .zig, zig build-exe -lc, run, pipe stderr. +; Epic gHashTag/t27#10782. + +module T27fPetalGlass; + +use t27f::petal_menu::QX; +use t27f::petal_menu::QY; +use t27f::petal_menu::BOX_CX; +use t27f::petal_menu::BOX_CY; +use t27f::petal_menu::PETAL_COUNT; +use t27f::petal_menu::PETAL_NONE; +use t27f::petal_menu::STATE_HOME; +use t27f::petal_menu::CARD_SLOTS; +use t27f::petal_menu::CARD_GAP_PX; +use t27f::petal_menu::OUTLINE_PX; +use t27f::petal_menu::IDLE_ALPHA; +use t27f::petal_menu::inside; +use t27f::petal_menu::fit_permille; +use t27f::petal_menu::to_box; +use t27f::petal_menu::hit_px; +use t27f::petal_menu::fill; +use t27f::petal_menu::outline; +use t27f::petal_menu::on_tap; +use t27f::petal_menu::on_back; +use t27f::petal_menu::card_count; +use t27f::petal_menu::card_x; +use t27f::petal_menu::card_y; +use t27f::reel_particles::quantize; + +// ---- frame ---- + +pub const W_PX : u32 = 960; +pub const H_PX : u32 = 540; +pub const WF : f64 = 960.0; +pub const HF : f64 = 540.0; +pub const FPS : u32 = 30; +pub const DT : f64 = 0.0333333333333333; +pub const FRAMES : u32 = 270; + +// ---- G1 desktop (suggested) ---- + +pub const DESK_TOP : [3]f64 = [0.16, 0.20, 0.36]; +pub const DESK_BOT : [3]f64 = [0.32, 0.17, 0.32]; + +// ---- G2..G5 glass (trios TriosVisualTheme / UnifiedTriosGlassBackground) ---- + +pub const TINT_BLACK : f64 = 0.30; +pub const WASH : f64 = 0.42; +pub const WASH_END : f64 = 0.7; +pub const BLOOM_OP : f64 = 0.055; +pub const BLOOM_MIN : f64 = 360.0; +pub const BLOOM_W : f64 = 0.62; +pub const BLOOM_SOFT : f64 = 0.3; +pub const BLOOM_RGB : [9]f64 = [0.18, 0.68, 0.34, 0.75, 0.34, 0.48, 0.30, 0.46, 0.75]; +pub const BLOOM_K : [3]f64 = [1.0, 0.8, 0.45]; +pub const BLOOM_SIZE_K : [3]f64 = [1.0, 0.82, 0.72]; +pub const BLOOM_BLUR : [3]f64 = [110.0, 120.0, 125.0]; +pub const BORDER_WHITE : f64 = 0.14; +pub const BORDER_PX : f64 = 0.5; +pub const SURFACE_BLACK : f64 = 0.46; +pub const HIGHLIGHT_WHITE : f64 = 0.08; + +// ---- A assembly (LogoAnimation) ---- + +pub const ANIM_DURATION : f64 = 2.5; +pub const START_OFF : f64 = 800.0; +pub const PHASE1_END : f64 = 0.7; +pub const PHASE1_RATE : f64 = 4.5; +pub const V_HANDOFF : f64 = 0.4; +pub const SPRING_K : f64 = 28.0; +pub const SPRING_DAMP : f64 = 0.86; +pub const TAU : f64 = 6.28318530717958647; + +// ---- M menu ---- + +pub const WAVE_RATE : f64 = 3.0; +pub const PANEL_W_PX : i64 = 600; +pub const PANEL_H_PX : i64 = 360; +pub const PANEL_R : f64 = 18.0; +pub const PANEL_LIFT : f64 = 24.0; +pub const CARD_R : f64 = 12.0; +pub const CARD_FILL : f64 = 0.06; +pub const CARD_EDGE : f64 = 0.10; +pub const HEADER_PX : f64 = 3.0; + +// ---- S scenario ---- + +pub const CURSOR_IN : u32 = 75; +pub const SWEEP_B : u32 = 100; +pub const SWEEP_C : u32 = 120; +pub const TAP_FRAME : u32 = 135; +pub const BACK_FRAME : u32 = 220; +pub const LEAVE_END : u32 = 260; +pub const TAP_PETAL : u32 = 13; + +// ---- small math ---- + +pub fn clamp01(x: f64) -> f64 { + if (x < 0.0) { return 0.0; } + if (x > 1.0) { return 1.0; } + return x; +} + +pub fn lerp(a: f64, b: f64, t: f64) -> f64 { + return a + (b - a) * t; +} + +pub fn ease(t: f64) -> f64 { + const u = clamp01(t); + return u * u * (3.0 - 2.0 * u); +} + +pub fn logistic(z: f64) -> f64 { + if (z > 40.0) { return 0.0; } + return 1.0 / (1.0 + @exp(z)); +} + +pub fn fmax(a: f64, b: f64) -> f64 { + if (a > b) { return a; } + return b; +} + +pub fn fmin(a: f64, b: f64) -> f64 { + if (a < b) { return a; } + return b; +} + +/// Channel k of a 0xAARRGGBB value in 0..1: 0 red, 1 green, 2 blue, 3 alpha. +pub fn chan(c: u32, k: u32) -> f64 { + var v : u32 = c % 256; + if (k == 3) { v = c / 16777216; } + if (k == 0) { v = (c / 65536) % 256; } + if (k == 1) { v = (c / 256) % 256; } + return @as(f64, @floatFromInt(v)) / 255.0; +} + +// ---- G glass background ---- + +/// G3. The darkWash opacity at gradient position t (0 top-left, 1 bottom-right). +pub fn wash(t: f64) -> f64 { + if (t < 0.5) { + return WASH * (1.0 - 2.0 * t); + } + return WASH * WASH_END * (2.0 * t - 1.0); +} + +/// G3. SwiftUI's unit-space projection of a pixel on the topLeading -> bottomTrailing line. +pub fn wash_t(x: f64, y: f64) -> f64 { + return clamp01((x * WF + y * HF) / (WF * WF + HF * HF)); +} + +pub fn bloom_size(w: f64) -> f64 { + return fmax(BLOOM_MIN, BLOOM_W * w); +} + +pub fn bloom_ox(k: u32) -> f64 { + if (k == 0) { return 0.38; } + if (k == 1) { return 0.68; } + return 0.0 - 0.16; +} + +pub fn bloom_oy(k: u32) -> f64 { + if (k == 0) { return 0.08; } + if (k == 1) { return 0.0 - 0.12; } + return 0.64; +} + +/// G1..G5. Channel ch of the glass window at pixel centre (x, y). +pub fn glass(ch: u32, x: f64, y: f64) -> f64 { + var c : f64 = lerp(DESK_TOP[ch], DESK_BOT[ch], y / HF); + c = c * (1.0 - TINT_BLACK); + c = c * (1.0 - wash(wash_t(x, y))); + const size = bloom_size(WF); + var k : u32 = 0; + while (k < 3) { + const bx = 0.5 * WF + bloom_ox(k) * WF; + const by = 0.5 * HF + bloom_oy(k) * HF; + const d = @sqrt((x - bx) * (x - bx) + (y - by) * (y - by)); + const r = 0.5 * size * BLOOM_SIZE_K[k]; + const a = BLOOM_OP * BLOOM_K[k] * logistic((d - r) / (BLOOM_SOFT * BLOOM_BLUR[k])); + c = c * (1.0 - a) + BLOOM_RGB[k * 3 + ch] * a; + k = k + 1; + } + const e = fmin(fmin(x, WF - x), fmin(y, HF - y)); + const ba = BORDER_WHITE * clamp01(BORDER_PX - (e - 0.5)); + return c * (1.0 - ba) + ba; +} + +// ---- P petal geometry in pixels ---- + +pub fn scale() -> f64 { + return @as(f64, @floatFromInt(fit_permille(960, 540))) / 1000.0; +} + +pub fn cen_x(p: u32) -> f64 { + var s : i64 = 0; + var v : u32 = 0; + while (v < 4) { s = s + QX[p * 4 + v]; v = v + 1; } + return @as(f64, @floatFromInt(s)) / 4.0; +} + +pub fn cen_y(p: u32) -> f64 { + var s : i64 = 0; + var v : u32 = 0; + while (v < 4) { s = s + QY[p * 4 + v]; v = v + 1; } + return @as(f64, @floatFromInt(s)) / 4.0; +} + +/// A. The unit direction a block flies in from: SVG centre -> its centroid, or i TAU / 27 when the +/// centroid sits on the centre (length <= 0.1 unit = 100 milli-units). +pub fn dir_x(p: u32) -> f64 { + const dx = cen_x(p) - @as(f64, @floatFromInt(BOX_CX)); + const dy = cen_y(p) - @as(f64, @floatFromInt(BOX_CY)); + const len = @sqrt(dx * dx + dy * dy); + if (len <= 100.0) { + return @cos(@as(f64, @floatFromInt(p)) * TAU / 27.0); + } + return dx / len; +} + +pub fn dir_y(p: u32) -> f64 { + const dx = cen_x(p) - @as(f64, @floatFromInt(BOX_CX)); + const dy = cen_y(p) - @as(f64, @floatFromInt(BOX_CY)); + const len = @sqrt(dx * dx + dy * dy); + if (len <= 100.0) { + return @sin(@as(f64, @floatFromInt(p)) * TAU / 27.0); + } + return dy / len; +} + +/// A box coordinate in milli-units to a viewport pixel, on the x or y axis. +pub fn to_px_x(mx: f64, s: f64) -> f64 { + return 0.5 * WF + (mx - @as(f64, @floatFromInt(BOX_CX))) / 1000.0 * s; +} + +pub fn to_px_y(my: f64, s: f64) -> f64 { + return 0.5 * HF + (my - @as(f64, @floatFromInt(BOX_CY))) / 1000.0 * s; +} + +/// Signed distance from (px, py) to the line a -> b, positive on the side away from (cx, cy). +pub fn edge_d(ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64, px: f64, py: f64) -> f64 { + const ex = bx - ax; + const ey = by - ay; + const len = @sqrt(ex * ex + ey * ey); + if (len < 0.000001) { + return 0.0 - 1000000000.0; + } + const nx = ey / len; + const ny = (0.0 - ex) / len; + const d = (px - ax) * nx + (py - ay) * ny; + if ((cx - ax) * nx + (cy - ay) * ny > 0.0) { + return 0.0 - d; + } + return d; +} + +/// Signed distance to a convex quad: negative inside, about one unit per unit outside an edge. +pub fn quad_sd(x0: f64, y0: f64, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, px: f64, py: f64) -> f64 { + const cx = 0.25 * (x0 + x1 + x2 + x3); + const cy = 0.25 * (y0 + y1 + y2 + y3); + var d : f64 = edge_d(x0, y0, x1, y1, cx, cy, px, py); + d = fmax(d, edge_d(x1, y1, x2, y2, cx, cy, px, py)); + d = fmax(d, edge_d(x2, y2, x3, y3, cx, cy, px, py)); + d = fmax(d, edge_d(x3, y3, x0, y0, cx, cy, px, py)); + return d; +} + +/// Petal p's signed distance in box milli-units, unmoved -- the form the test compares with inside(). +pub fn petal_sd_box(p: u32, mx: f64, my: f64) -> f64 { + const i = p * 4; + return quad_sd(@as(f64, @floatFromInt(QX[i])), @as(f64, @floatFromInt(QY[i])), + @as(f64, @floatFromInt(QX[i + 1])), @as(f64, @floatFromInt(QY[i + 1])), + @as(f64, @floatFromInt(QX[i + 2])), @as(f64, @floatFromInt(QY[i + 2])), + @as(f64, @floatFromInt(QX[i + 3])), @as(f64, @floatFromInt(QY[i + 3])), mx, my); +} + +// ---- A assembly ---- + +/// A. How far (SVG units) every block still is from home after `frame` updates at 30 fps. +pub fn anim_off(frame: u32) -> f64 { + var off : f64 = START_OFF; + var v : f64 = 0.0; + var f : u32 = 0; + while (f < frame) { + const t = @as(f64, @floatFromInt(f)) * DT; + if (t / ANIM_DURATION < PHASE1_END) { + off = off - off * PHASE1_RATE * DT; + v = 0.0 - off * V_HANDOFF; + } else { + v = v + (0.0 - off) * SPRING_K * DT; + v = v * SPRING_DAMP; + off = off + v * DT * 60.0; + } + f = f + 1; + } + return off; +} + +// ---- S scenario ---- + +pub fn tap_x() -> f64 { + return to_px_x(cen_x(TAP_PETAL), scale()); +} + +pub fn tap_y() -> f64 { + return to_px_y(cen_y(TAP_PETAL), scale()); +} + +fn seg(f: u32, f0: u32, f1: u32) -> f64 { + return ease(@as(f64, @floatFromInt(f - f0)) / @as(f64, @floatFromInt(f1 - f0))); +} + +pub fn cursor_x(f: u32) -> f64 { + if (f < CURSOR_IN) { return 0.28 * WF; } + if (f < SWEEP_B) { return lerp(0.28 * WF, 0.50 * WF, seg(f, CURSOR_IN, SWEEP_B)); } + if (f < SWEEP_C) { return lerp(0.50 * WF, 0.70 * WF, seg(f, SWEEP_B, SWEEP_C)); } + if (f < TAP_FRAME) { return lerp(0.70 * WF, tap_x(), seg(f, SWEEP_C, TAP_FRAME)); } + if (f < BACK_FRAME + 10) { return tap_x(); } + if (f < LEAVE_END) { return lerp(tap_x(), 0.88 * WF, seg(f, BACK_FRAME + 10, LEAVE_END)); } + return 0.88 * WF; +} + +pub fn cursor_y(f: u32) -> f64 { + if (f < CURSOR_IN) { return 0.80 * HF; } + if (f < SWEEP_B) { return lerp(0.80 * HF, 0.22 * HF, seg(f, CURSOR_IN, SWEEP_B)); } + if (f < SWEEP_C) { return lerp(0.22 * HF, 0.80 * HF, seg(f, SWEEP_B, SWEEP_C)); } + if (f < TAP_FRAME) { return lerp(0.80 * HF, tap_y(), seg(f, SWEEP_C, TAP_FRAME)); } + if (f < BACK_FRAME + 10) { return tap_y(); } + if (f < LEAVE_END) { return lerp(tap_y(), 0.88 * HF, seg(f, BACK_FRAME + 10, LEAVE_END)); } + return 0.88 * HF; +} + +pub fn cursor_on(f: u32) -> bool { + return f >= CURSOR_IN; +} + +/// S + L6. The menu state at frame f: HOME, then whatever petal_menu.on_tap opens under the cursor, +/// then petal_menu.on_back. +pub fn state_at(f: u32) -> u32 { + if (f < TAP_FRAME) { + return STATE_HOME; + } + const s = fit_permille(960, 540); + const cx = @as(i64, @intFromFloat(cursor_x(TAP_FRAME))); + const cy = @as(i64, @intFromFloat(cursor_y(TAP_FRAME))); + const opened = on_tap(STATE_HOME, to_box(cx, 480, BOX_CX, s), to_box(cy, 270, BOX_CY, s)); + if (f < BACK_FRAME) { + return opened; + } + return on_back(opened); +} + +/// M. g_wave_transition: up by dt x 3 a frame after the tap, down the same way after back. +pub fn menu_alpha(f: u32) -> f64 { + if (f < TAP_FRAME) { return 0.0; } + if (f < BACK_FRAME) { + return clamp01(@as(f64, @floatFromInt(f - TAP_FRAME)) * WAVE_RATE / 30.0); + } + return clamp01(1.0 - @as(f64, @floatFromInt(f - BACK_FRAME)) * WAVE_RATE / 30.0); +} + +/// S + L3. The hovered petal: the cursor's petal on HOME, none while a menu is up or fading. +pub fn hover_at(f: u32) -> u32 { + if (cursor_on(f) == false or state_at(f) != STATE_HOME or menu_alpha(f) > 0.0) { + return PETAL_NONE; + } + const cx = @as(i64, @intFromFloat(cursor_x(f))); + const cy = @as(i64, @intFromFloat(cursor_y(f))); + return hit_px(cx, cy, 960, 540); +} + +// ---- M panel shape ---- + +pub fn rrect(x: f64, y: f64, hw: f64, hh: f64, r: f64) -> f64 { + const qx = @abs(x) - hw + r; + const qy = @abs(y) - hh + r; + const mx = fmax(qx, 0.0); + const my = fmax(qy, 0.0); + return @sqrt(mx * mx + my * my) + fmin(fmax(qx, qy), 0.0) - r; +} + +// ---- the render ---- + +pub fn main() -> void { + var vxs : [108]f64 = undefined; + var vys : [108]f64 = undefined; + var bx0 : [27]f64 = undefined; + var bx1 : [27]f64 = undefined; + var by0 : [27]f64 = undefined; + var by1 : [27]f64 = undefined; + var roww : [2880]u8 = undefined; + const s = scale(); + const ow = @as(f64, @floatFromInt(OUTLINE_PX)); + const pw = @as(f64, @floatFromInt(PANEL_W_PX)); + const ph = @as(f64, @floatFromInt(PANEL_H_PX)); + const gap = @as(f64, @floatFromInt(CARD_GAP_PX)); + const cw = (pw - 4.0 * gap) / 3.0; + const chh = (ph - 4.0 * gap) / 3.0; + const head = fill(TAP_PETAL, TAP_PETAL, PETAL_NONE); + const oc = outline(); + var f : u32 = 0; + while (f < FRAMES) { + const off = anim_off(f) * 1000.0; + const st = state_at(f); + const hov = hover_at(f); + const ma = menu_alpha(f); + const curx = cursor_x(f); + const cury = cursor_y(f); + const pcx = 0.5 * WF; + const pcy = 0.5 * HF + (1.0 - ma) * PANEL_LIFT; + const left = pcx - 0.5 * pw; + const top = pcy - 0.5 * ph; + var p : u32 = 0; + while (p < PETAL_COUNT) { + const ddx = dir_x(p) * off; + const ddy = dir_y(p) * off; + var lx : f64 = 1000000000.0; + var hx : f64 = 0.0 - 1000000000.0; + var ly : f64 = 1000000000.0; + var hy : f64 = 0.0 - 1000000000.0; + var v : u32 = 0; + while (v < 4) { + const i = p * 4 + v; + const X = to_px_x(@as(f64, @floatFromInt(QX[i])) + ddx, s); + const Y = to_px_y(@as(f64, @floatFromInt(QY[i])) + ddy, s); + vxs[i] = X; + vys[i] = Y; + lx = fmin(lx, X); + hx = fmax(hx, X); + ly = fmin(ly, Y); + hy = fmax(hy, Y); + v = v + 1; + } + bx0[p] = lx - 2.0; + bx1[p] = hx + 2.0; + by0[p] = ly - 2.0; + by1[p] = hy + 2.0; + p = p + 1; + } + var py : u32 = 0; + while (py < H_PX) { + const y = @as(f64, @floatFromInt(py)) + 0.5; + var px : u32 = 0; + while (px < W_PX) { + const x = @as(f64, @floatFromInt(px)) + 0.5; + var r : f64 = glass(0, x, y); + var g : f64 = glass(1, x, y); + var b : f64 = glass(2, x, y); + // P: the 27 petals, fill then the 1 px outline + p = 0; + while (p < PETAL_COUNT) { + if (x >= bx0[p] and x <= bx1[p] and y >= by0[p] and y <= by1[p]) { + const i = p * 4; + const sd = quad_sd(vxs[i], vys[i], vxs[i + 1], vys[i + 1], vxs[i + 2], vys[i + 2], vxs[i + 3], vys[i + 3], x, y); + const cov = clamp01(0.5 - sd); + if (cov > 0.0) { + const fc = fill(p, st, hov); + const a = chan(fc, 3) * cov; + r = r * (1.0 - a) + chan(fc, 0) * a; + g = g * (1.0 - a) + chan(fc, 1) * a; + b = b * (1.0 - a) + chan(fc, 2) * a; + } + const ol = clamp01(ow - @abs(sd)) * chan(oc, 3); + if (ol > 0.0) { + r = r * (1.0 - ol) + chan(oc, 0) * ol; + g = g * (1.0 - ol) + chan(oc, 1) * ol; + b = b * (1.0 - ol) + chan(oc, 2) * ol; + } + } + p = p + 1; + } + // M: the menu, a blackGlass panel over a black 0.30 scrim, with the empty card slots + if (ma > 0.0) { + const sc = 1.0 - TINT_BLACK * ma; + r = r * sc; + g = g * sc; + b = b * sc; + const sd = rrect(x - pcx, y - pcy, 0.5 * pw, 0.5 * ph, PANEL_R); + const pin = clamp01(0.5 - sd) * ma; + if (pin > 0.0) { + const dk = 1.0 - SURFACE_BLACK * pin; + const hl = HIGHLIGHT_WHITE * pin * clamp01(1.0 - (y - top) / ph); + r = r * dk + hl; + g = g * dk + hl; + b = b * dk + hl; + if (y - top < HEADER_PX + 0.5 and y - top > 0.0 and sd < 0.0 - PANEL_R * 0.5) { + const ha = pin; + r = r * (1.0 - ha) + chan(head, 0) * ha; + g = g * (1.0 - ha) + chan(head, 1) * ha; + b = b * (1.0 - ha) + chan(head, 2) * ha; + } + var k : u32 = 0; + while (k < CARD_SLOTS) { + const kx = left + @as(f64, @floatFromInt(card_x(k, PANEL_W_PX))) + 0.5 * cw; + const ky = top + @as(f64, @floatFromInt(card_y(k, PANEL_H_PX))) + 0.5 * chh; + const sdc = rrect(x - kx, y - ky, 0.5 * cw, 0.5 * chh, CARD_R); + const cf = clamp01(0.5 - sdc) * CARD_FILL * pin; + const ce = clamp01(1.0 - @abs(sdc)) * CARD_EDGE * pin; + const ca = cf + ce; + r = r * (1.0 - ca) + ca; + g = g * (1.0 - ca) + ca; + b = b * (1.0 - ca) + ca; + k = k + 1; + } + } + const bd = clamp01(1.0 - @abs(sd)) * BORDER_WHITE * ma; + r = r * (1.0 - bd) + bd; + g = g * (1.0 - bd) + bd; + b = b * (1.0 - bd) + bd; + } + // S: the cursor ring, and the tap ripple + if (cursor_on(f)) { + const d = @sqrt((x - curx) * (x - curx) + (y - cury) * (y - cury)); + const sh = clamp01(1.2 - @abs(d - 9.0)) * 0.45; + r = r * (1.0 - sh); + g = g * (1.0 - sh); + b = b * (1.0 - sh); + const ring = clamp01(1.4 - @abs(d - 7.0)) * 0.92; + r = r * (1.0 - ring) + ring; + g = g * (1.0 - ring) + ring; + b = b * (1.0 - ring) + ring; + if (f >= TAP_FRAME and f < TAP_FRAME + 10) { + const tt = @as(f64, @floatFromInt(f - TAP_FRAME)); + const rip = clamp01(1.5 - @abs(d - (9.0 + 3.0 * tt))) * 0.6 * (1.0 - tt / 10.0); + r = r * (1.0 - rip) + rip; + g = g * (1.0 - rip) + rip; + b = b * (1.0 - rip) + rip; + } + } + roww[px * 3] = quantize(r * 255.0); + roww[px * 3 + 1] = quantize(g * 255.0); + roww[px * 3 + 2] = quantize(b * 255.0); + px = px + 1; + } + std.debug.print("{s}", .{roww}); + py = py + 1; + } + f = f + 1; + } +} + +// ---- tests ---- + +test the_blocks_fly_in_from_800_units_and_settle_home { + assert anim_off(0) == START_OFF; + assert anim_off(15) > 50.0; + assert anim_off(15) < 100.0; + assert @abs(anim_off(53)) < 0.2; + assert @abs(anim_off(75)) < 0.05; + assert @abs(anim_off(FRAMES)) < 0.001; +} + +test the_menu_fades_in_over_ten_frames_and_out_after_back { + assert menu_alpha(TAP_FRAME - 1) == 0.0; + assert menu_alpha(TAP_FRAME) == 0.0; + assert @abs(menu_alpha(TAP_FRAME + 5) - 0.5) < 0.000001; + assert menu_alpha(TAP_FRAME + 10) == 1.0; + assert menu_alpha(BACK_FRAME - 1) == 1.0; + assert @abs(menu_alpha(BACK_FRAME + 5) - 0.5) < 0.000001; + assert menu_alpha(BACK_FRAME + 10) == 0.0; +} + +test the_scenario_taps_petal_13_and_backs_home { + assert state_at(TAP_FRAME - 1) == STATE_HOME; + assert state_at(TAP_FRAME) == TAP_PETAL; + assert state_at(BACK_FRAME - 1) == TAP_PETAL; + assert state_at(BACK_FRAME) == STATE_HOME; + assert card_count(TAP_PETAL) == 0; + assert hover_at(CURSOR_IN - 1) == PETAL_NONE; + assert hover_at(TAP_FRAME - 1) == TAP_PETAL; + assert hover_at(TAP_FRAME + 20) == PETAL_NONE; + assert hover_at(FRAMES - 1) == PETAL_NONE; +} + +test the_distance_agrees_with_the_ray_cast { + var p : u32 = 0; + while (p < PETAL_COUNT) { + var lx : i64 = QX[p * 4]; + var hx : i64 = QX[p * 4]; + var ly : i64 = QY[p * 4]; + var hy : i64 = QY[p * 4]; + var v : u32 = 1; + while (v < 4) { + if (QX[p * 4 + v] < lx) { lx = QX[p * 4 + v]; } + if (QX[p * 4 + v] > hx) { hx = QX[p * 4 + v]; } + if (QY[p * 4 + v] < ly) { ly = QY[p * 4 + v]; } + if (QY[p * 4 + v] > hy) { hy = QY[p * 4 + v]; } + v = v + 1; + } + var a : i64 = 0; + while (a <= 20) { + var c : i64 = 0; + while (c <= 20) { + const mx = lx + @divTrunc((hx - lx) * a, 20) + 7; + const my = ly + @divTrunc((hy - ly) * c, 20) + 3; + const sd = petal_sd_box(p, @as(f64, @floatFromInt(mx)), @as(f64, @floatFromInt(my))); + if (sd < 0.0 - 50.0) { assert inside(p, mx, my); } + if (sd > 50.0) { assert inside(p, mx, my) == false; } + c = c + 1; + } + a = a + 1; + } + assert petal_sd_box(p, cen_x(p), cen_y(p)) < 0.0; + p = p + 1; + } +} + +test the_glass_carries_trios_numbers { + assert wash(0.0) == WASH; + assert wash(0.5) == 0.0; + assert @abs(wash(1.0) - 0.294) < 0.000001; + assert @abs(bloom_size(WF) - 595.2) < 0.000001; + assert bloom_size(400.0) == BLOOM_MIN; + assert @abs(chan(fill(0, STATE_HOME, PETAL_NONE), 3) - 0.2196) < 0.001; + assert IDLE_ALPHA == 56; + assert chan(fill(0, STATE_HOME, 0), 0) == 1.0; +} + +test every_petal_flies_out_along_a_unit_direction { + var p : u32 = 0; + while (p < PETAL_COUNT) { + const dx = dir_x(p); + const dy = dir_y(p); + assert @abs(dx * dx + dy * dy - 1.0) < 0.000001; + p = p + 1; + } +} diff --git a/specs/t27os/agents.t27 b/specs/t27os/agents.t27 new file mode 100644 index 0000000000..31594c1692 --- /dev/null +++ b/specs/t27os/agents.t27 @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/agents.t27 -- the coding agents T27 OS runs in its honeycomb, their session states and +; what happens to each session after the machine restarts. +; Owner, 2026-10-19: the honeycomb cells carry the marks of Claude Code, Codex, Crush, OpenCode, Gemini +; and others; chats are orchestrated from our app and sessions come back after a reboot. +; Studied first: herdr.dev (Apache 2.0, v0.9.5). Its model, which this spec follows: +; a background server owns every agent terminal, so closing the window does not stop an agent; +; each pane is read and marked working, blocked or idle, so the one waiting on you is shown; +; after a restart the layout comes back and each agent's own session is resumed; +; the agent CLIs are not wrapped or replaced, only their terminals are owned. +; Cell 4, the centre of the comb, is the Queen: our orchestrator, the one that prompts the others. +; The resume commands are each CLI's documented flag (Claude Code --resume, Codex resume, Gemini --resume, +; OpenCode --session, Cursor --resume, Aider --restore-chat-history, Copilot --resume). They are not yet +; probed on this machine; RESUME_UNKNOWN marks a CLI whose flag is not established, and such a session is +; relaunched fresh in its working directory instead of resumed. +; Host side still missing (follow-up): the terminal owner (posix_spawn + pty), the on-disk store of +; session records, and the state reader. This spec is the decisions they will read. + +module T27osAgents; + +pub const AGENT_COUNT : u32 = 9; +pub const AGENT_QUEEN : u32 = 4; +/// Names in cell order: top row, middle row (Queen in the centre), bottom row. +pub const AGENT_NAMES : str = "Claude Code,Codex,Gemini,OpenCode,Queen,Crush,Cursor,Aider,Copilot"; +/// The command each cell launches. +pub const AGENT_BINS : str = "claude,codex,gemini,opencode,queen,crush,cursor-agent,aider,copilot"; + +/// How an agent's own session is brought back. +pub const RESUME_BY_ID : u32 = 0; +pub const RESUME_HISTORY : u32 = 1; +pub const RESUME_UNKNOWN : u32 = 2; +/// Per cell: Claude, Codex, Gemini, OpenCode, Queen, Crush, Cursor, Aider, Copilot. +pub const RESUME_KIND : [9]u32 = [0, 0, 0, 0, 2, 2, 0, 1, 0]; + +/// Session states, herdr's four plus STOPPED (no process: never started, crashed, or the machine restarted). +pub const S_STOPPED : u32 = 0; +pub const S_WORKING : u32 = 1; +pub const S_BLOCKED : u32 = 2; +pub const S_IDLE : u32 = 3; +pub const S_DONE : u32 = 4; + +/// Events the terminal owner reports. +pub const E_OUTPUT : u32 = 0; +pub const E_PROMPT : u32 = 1; +pub const E_QUIET : u32 = 2; +pub const E_EXIT_OK : u32 = 3; +pub const E_EXIT_FAIL : u32 = 4; +pub const E_REBOOT : u32 = 5; +pub const E_INPUT : u32 = 6; + +/// What a saved session gets when the app starts again. +pub const R_SKIP : u32 = 0; +pub const R_RESUME : u32 = 1; +pub const R_RELAUNCH : u32 = 2; + +/// The next state of a session in state s on event e. +pub fn on_event(s: u32, e: u32) -> u32 { + if (e == E_REBOOT) { return S_STOPPED; } + if (e == E_EXIT_OK) { return S_DONE; } + if (e == E_EXIT_FAIL) { return S_STOPPED; } + if (s == S_STOPPED or s == S_DONE) { return s; } + if (e == E_OUTPUT or e == E_INPUT) { return S_WORKING; } + if (e == E_PROMPT) { return S_BLOCKED; } + if (e == E_QUIET and s == S_WORKING) { return S_IDLE; } + return s; +} + +/// True when the session needs the user: the cell is lit so nobody hunts pane by pane. +pub fn needs_you(s: u32) -> bool { + return s == S_BLOCKED; +} + +/// What restart does with a session of agent a that was in state s when it was saved. +pub fn restore_action(a: u32, s: u32) -> u32 { + if (s == S_DONE) { return R_SKIP; } + if (a >= AGENT_COUNT) { return R_SKIP; } + if (RESUME_KIND[a] == RESUME_UNKNOWN) { return R_RELAUNCH; } + return R_RESUME; +} + +/// The state a restored session shows before its process says anything. +pub fn restored_state(a: u32, s: u32) -> u32 { + if (restore_action(a, s) == R_SKIP) { return s; } + return S_IDLE; +} + +test the_queen_sits_in_the_centre_cell { + assert AGENT_QUEEN == 4; + assert AGENT_COUNT == 9; + assert RESUME_KIND[AGENT_QUEEN] == RESUME_UNKNOWN; +} + +test states_follow_the_terminal { + assert on_event(S_IDLE, E_OUTPUT) == S_WORKING; + assert on_event(S_WORKING, E_PROMPT) == S_BLOCKED; + assert needs_you(on_event(S_WORKING, E_PROMPT)); + assert on_event(S_BLOCKED, E_INPUT) == S_WORKING; + assert on_event(S_WORKING, E_QUIET) == S_IDLE; + assert on_event(S_BLOCKED, E_QUIET) == S_BLOCKED; + assert on_event(S_WORKING, E_EXIT_OK) == S_DONE; + assert on_event(S_DONE, E_OUTPUT) == S_DONE; + assert on_event(S_WORKING, E_REBOOT) == S_STOPPED; +} + +test a_reboot_brings_every_unfinished_session_back { + // Claude Code was mid-task: resumed by its own session id + assert restore_action(0, on_event(S_WORKING, E_REBOOT)) == R_RESUME; + // Crush's resume flag is not established: relaunched fresh in the same directory + assert restore_action(5, S_BLOCKED) == R_RELAUNCH; + // a finished session stays finished + assert restore_action(1, S_DONE) == R_SKIP; + assert restored_state(0, S_STOPPED) == S_IDLE; + assert restored_state(1, S_DONE) == S_DONE; + // no agent outside the comb is restored + assert restore_action(AGENT_COUNT, S_IDLE) == R_SKIP; +} diff --git a/specs/t27os/doctor.t27 b/specs/t27os/doctor.t27 new file mode 100644 index 0000000000..57a4f082f8 --- /dev/null +++ b/specs/t27os/doctor.t27 @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/doctor.t27 -- the repositories T27 OS finds on this machine, the SPACES it makes of them, and +; the DOCTOR that says which copies are left over and what they cost on disk. +; Owner, 2026-10-19: by default T27 OS finds every git repository on the machine and makes a SPACE per +; repository, the user can hide one; the scan keeps running, so new clones and worktrees appear and deleted +; ones vanish; a DOCTOR panel flags duplicate clones of one remote, leftover agent worktrees, folders agents +; made several times, and the disk each repository takes; it suggests what to remove, and nothing is removed +; until the human confirms each item -- a repository with unpushed commits or uncommitted changes is never +; offered for removal at all. +; This spec is the decisions. The host (glass_window.t27) does only the plumbing: it lists directories, runs +; the git and du command lines below one at a time, reads their output, and moves a confirmed item to the +; Trash (reversible, never an unlink). +; The scan is a walk, not an FS-event stream: the host cannot hand a C callback to FSEvents yet, so it walks +; SCAN_BUDGET directories a frame, starts a new pass RESCAN_FRAMES after the last one ended, and re-checks one +; known repository's .git every frame. A clone appears within one pass; a deleted one vanishes as soon as its +; turn in the re-check comes round (VANISH_FRAMES at most for a list of LIST_MAX). +; Not measured here: memory per repository. Disk is measured; RAM belongs to processes, not folders, and is +; left to the terminal owner (agents.t27) that will know which agent runs in which repository. + +module T27osDoctor; + +/// Where the walk starts, in order, and how deep it goes below each root. HOME is the user's home directory. +pub const ROOT_HOME : u32 = 0; +pub const ROOT_TMP : u32 = 1; +pub const ROOT_COUNT : u32 = 2; +pub const ROOT_TMP_PATH : str = "/private/tmp"; +pub const DEPTH_HOME : u32 = 5; +pub const DEPTH_TMP : u32 = 3; +/// Directory names the walk never enters: caches, build output, package stores, media and app bundles. +/// Desktop, Documents, Dropbox and OrbStack are cloud- or VM-backed (a stat there blocked the walk for minutes); Downloads holds archives. +pub const SCAN_SKIP : str = "node_modules,target,Library,Applications,Movies,Music,Pictures,Desktop,Documents,Downloads,Dropbox,OrbStack,build,dist,DerivedData,Pods,zig-cache,zig-out,vendor,__pycache__"; +/// A name starting with "." is skipped, except these: agent worktrees live under .claude/worktrees. +pub const HIDDEN_PREFIX : str = "."; +pub const HIDDEN_ALLOWED : str = ".claude,.codex,.gemini"; +/// Walk pacing (frames at 30 fps). +pub const SCAN_BUDGET : u32 = 24; +pub const RESCAN_FRAMES : u32 = 900; +/// The checks pace themselves the same way: a pass over the list, then RESCAN_FRAMES of rest. +pub const LIST_MAX : u32 = 600; +pub const VANISH_FRAMES : u32 = 600; + +/// Paths that only agents make: a repository below one of these is an agent copy. +pub const AGENT_ROOTS : str = "/private/tmp/,/tmp/,/t27-lanes/,/.claude/worktrees/,/.codex/,/.gemini/"; + +/// A folder name with these endings stripped is its stem; REPEAT_MIN folders with one stem under one parent +/// are "made several times" (t27-2, t27-3, t27-copy, t27-old ...). A regular expression, applied by the host. +pub const STEM_PATTERN : str = "([-_. ](copy|old|bak|tmp|wip|new|[0-9]+))+$"; +pub const REPEAT_MIN : u32 = 3; + +/// The checks run on every repository, in order. {p} is the repository path; arguments are split on "|". +pub const JOB_STATUS : u32 = 0; +pub const JOB_UNPUSHED : u32 = 1; +pub const JOB_REMOTE : u32 = 2; +pub const JOB_SIZE : u32 = 3; +pub const JOB_BUILD : u32 = 4; +pub const JOB_COUNT : u32 = 5; +pub const JOB_LINES : str = "/usr/bin/git|-C|{p}|status|--porcelain=v2|--branch|--show-stash;/usr/bin/git|-C|{p}|log|HEAD|--branches|--not|--remotes|-1|--format=%h;/usr/bin/git|-C|{p}|remote|get-url|origin;/usr/bin/du|-sk|{p};/usr/bin/du|-sk|{p}/target|{p}/node_modules|{p}/.zig-cache|{p}/zig-out|{p}/build|{p}/dist"; +pub const JOB_SEP : str = ";"; +pub const ARG_SEP : str = "|"; +pub const PATH_MARK : str = "{p}"; +/// How many checks run at once, and how many passes over the list pass between two size checks (du is slow). +pub const JOB_SLOTS : u32 = 4; +pub const SIZE_EVERY : u32 = 4; + +/// Reading `git status --porcelain=v2 --branch --show-stash`: header lines start with "# ", every other +/// non-empty line is a changed or untracked path. +pub const ST_HEADER : str = "# "; +pub const ST_HEAD : str = "# branch.head "; +pub const ST_UPSTREAM : str = "# branch.upstream "; +pub const ST_STASH : str = "# stash "; + +/// What the DOCTOR knows about a repository, one bit each. +pub const F_CHECKED : u32 = 1; +pub const F_WORKTREE : u32 = 2; +pub const F_AGENT : u32 = 4; +pub const F_DUP : u32 = 8; +pub const F_REPEATED : u32 = 16; +pub const F_HEAVY : u32 = 32; +pub const F_BUILD : u32 = 64; +pub const F_DIRTY : u32 = 128; +pub const F_UNPUSHED : u32 = 256; +pub const F_STASH : u32 = 512; +pub const F_HAS_WORKTREES : u32 = 1024; +pub const F_NESTS : u32 = 2048; +pub const F_NO_REMOTE : u32 = 4096; +pub const F_PUSH_CHECKED : u32 = 8192; +pub const F_BOUND : u32 = 16384; +pub const F_HIDDEN : u32 = 32768; +pub const FLAG_BITS : u32 = 16; +/// The words a DOCTOR row shows, in bit order; an empty word is a bit the row does not print. +pub const FLAG_WORDS : str = ",worktree,agent copy,duplicate,made again,heavy,build dirs,uncommitted,unpushed,stash,has worktrees,holds repos,no remote,,,hidden"; + +/// A repository is heavy at HEAVY_MB, and its build directories are worth naming at BUILD_MB. +pub const HEAVY_MB : u64 = 1024; +pub const BUILD_MB : u64 = 200; + +/// Why a leftover is kept, in the order a row names the first that applies. +pub const KEEP_NONE : u32 = 0; +pub const KEEP_UNCHECKED : u32 = 1; +pub const KEEP_DIRTY : u32 = 2; +pub const KEEP_UNPUSHED : u32 = 3; +pub const KEEP_STASH : u32 = 4; +pub const KEEP_WORKTREES : u32 = 5; +pub const KEEP_NESTS : u32 = 6; +pub const KEEP_NO_REMOTE : u32 = 7; +pub const KEEP_WORDS : str = ",checking,keep: uncommitted changes,keep: unpushed commits,keep: has a stash,keep: other worktrees use it,keep: holds other repos,keep: no remote copy"; + +/// DOCTOR row groups, shown in this order. +pub const ROW_REMOVABLE : u32 = 0; +pub const ROW_KEEP : u32 = 1; +pub const ROW_INFO : u32 = 2; +pub const ROW_CLEAN : u32 = 3; +pub const ROW_GROUPS : u32 = 3; + +/// The two clicks a removal takes: the first arms the row (it reads CONFIRM), the second moves it to the +/// Trash. Any other click disarms. +pub const ARMED_NONE : u32 = 4294967295; +pub const CLICK_ARM : u32 = 0; +pub const CLICK_REMOVE : u32 = 1; +pub const CLICK_DISARM : u32 = 2; + +/// Space binding: which repositories fill the six sidebar slots on their own, and which only "+" offers. +pub const RANK_AUTO : u32 = 0; +pub const RANK_PLUS : u32 = 1; +pub const RANK_NEVER : u32 = 2; +/// Spaces the sidebar fills on its own at first start (the rest stay closed for "+"), seeded with these paths +/// below HOME when they exist. +pub const SPACES_AUTO : u32 = 3; +pub const SEED_PATHS : str = "t27,trinity,t27-t27os"; + +/// DOCTOR panel layout, in the Queen panel's column (queen.t27 QP_W). +pub const DOC_HEAD_Y : f64 = 25.0; +pub const DOC_SUM_Y : f64 = 52.0; +pub const DOC_ROWS_Y : f64 = 96.0; +pub const DOC_ROW_H : f64 = 48.0; +pub const DOC_BTN_W : f64 = 64.0; +pub const DOC_BTN_H : f64 = 20.0; +/// The button sits on the second line of a row; the foot link "DOCTOR n" takes the right end of the sidebar foot. +pub const DOC_BTN_Y : f64 = 17.0; +pub const DOC_LINK_W : f64 = 84.0; +pub const DOC_WORDS : str = "DOCTOR,repositories,checked,can go,kept,scanning,watching,remove,confirm,nothing to remove,MB,GB,DOCTOR "; +pub const DL_TITLE : u32 = 0; +pub const DL_REPOS : u32 = 1; +pub const DL_CHECKED : u32 = 2; +pub const DL_CAN_GO : u32 = 3; +pub const DL_KEPT : u32 = 4; +pub const DL_SCANNING : u32 = 5; +pub const DL_WATCHING : u32 = 6; +pub const DL_REMOVE : u32 = 7; +pub const DL_CONFIRM : u32 = 8; +pub const DL_NOTHING : u32 = 9; +pub const DL_MB : u32 = 10; +pub const DL_GB : u32 = 11; +pub const DL_FOOT : u32 = 12; + +fn flag_bit(k: u32) -> u32 { + var p : u32 = 1; + var i : u32 = 0; + while (i < k) { + p = p * 2; + i = i + 1; + } + return p; +} + +/// Whether bit f is set in flags. +pub fn has(flags: u32, f: u32) -> bool { + return flags / f % 2 == 1; +} + +/// flags with bit f set (or cleared when on is false). +pub fn with_flag(flags: u32, f: u32, on: bool) -> u32 { + if (has(flags, f) == on) { return flags; } + if (on) { return flags + f; } + return flags - f; +} + +/// Whether flag word k is printed for these flags. +pub fn shows_word(flags: u32, k: u32) -> bool { + if (k >= FLAG_BITS) { return false; } + const f : u32 = flag_bit(k); + if (f == F_CHECKED or f == F_PUSH_CHECKED or f == F_BOUND) { return false; } + return has(flags, f); +} + +/// How deep the walk goes below root r. +pub fn root_depth(r: u32) -> u32 { + if (r == ROOT_TMP) { return DEPTH_TMP; } + return DEPTH_HOME; +} + +/// Whether the walk enters a directory at depth d (the root is depth 0) below root r, given whether its name +/// is on SCAN_SKIP, hidden, or one of HIDDEN_ALLOWED. +pub fn enters(r: u32, d: u32, skipped: bool, hidden: bool, allowed: bool) -> bool { + if (d > root_depth(r)) { return false; } + if (skipped) { return false; } + if (hidden and allowed == false) { return false; } + return true; +} + +/// Whether a walk pass that has `queued` directories left is over. +pub fn pass_done(queued: u32) -> bool { + return queued == 0; +} + +/// Whether a new pass starts, `idle` frames after the last one ended. +pub fn pass_due(idle: u32) -> bool { + return idle >= RESCAN_FRAMES; +} + +/// Whether job j runs on this pass over the list (passes counted from 0): the size jobs only every SIZE_EVERY. +pub fn job_due(j: u32, pass: u32) -> bool { + if (j >= JOB_COUNT) { return false; } + if (j == JOB_SIZE or j == JOB_BUILD) { return pass % SIZE_EVERY == 0; } + return true; +} + +/// Whether folders sharing a stem under one parent are "made several times". +pub fn repeated(count: u32) -> bool { + return count >= REPEAT_MIN; +} + +/// Whether one folder of such a group is a copy: its name carries a suffix (t27-2), so it is not the original +/// (t27), which keeps its plain name and is never called made again. +pub fn made_again(count: u32, suffixed: bool) -> bool { + return suffixed and repeated(count); +} + +/// Whether a repository of kb kilobytes is heavy, and whether its build directories are worth naming. +pub fn heavy(kb: u64) -> bool { + return kb >= HEAVY_MB * 1024; +} + +pub fn build_heavy(kb: u64) -> bool { + return kb >= BUILD_MB * 1024; +} + +/// A size in the row: GB with one decimal from 1024 MB up, else whole MB. +pub fn size_in_gb(kb: u64) -> bool { + return kb >= 1024 * 1024; +} + +pub fn size_whole(kb: u64) -> u64 { + if (size_in_gb(kb)) { return kb / (1024 * 1024); } + return kb / 1024; +} + +pub fn size_tenth(kb: u64) -> u64 { + if (size_in_gb(kb)) { return kb % (1024 * 1024) * 10 / (1024 * 1024); } + return 0; +} + +/// Why a repository is kept, KEEP_NONE when nothing holds it. Each check must have run and answered: a +/// repository whose status or unpushed check has not come back yet is kept as "checking". +pub fn keep_reason(flags: u32) -> u32 { + if (has(flags, F_CHECKED) == false or has(flags, F_PUSH_CHECKED) == false) { return KEEP_UNCHECKED; } + if (has(flags, F_DIRTY)) { return KEEP_DIRTY; } + if (has(flags, F_UNPUSHED)) { return KEEP_UNPUSHED; } + if (has(flags, F_STASH)) { return KEEP_STASH; } + if (has(flags, F_HAS_WORKTREES)) { return KEEP_WORKTREES; } + if (has(flags, F_NESTS)) { return KEEP_NESTS; } + if (has(flags, F_NO_REMOTE)) { return KEEP_NO_REMOTE; } + return KEEP_NONE; +} + +/// Whether the DOCTOR calls this repository a leftover: a duplicate clone that is not the keeper, an agent +/// copy, or one of a folder made several times. A space bound in the sidebar is in use, not a leftover. +pub fn leftover(flags: u32) -> bool { + if (has(flags, F_BOUND)) { return false; } + return has(flags, F_DUP) or has(flags, F_AGENT) or has(flags, F_REPEATED); +} + +/// Whether a row offers the remove button: a leftover that nothing keeps. +pub fn removable(flags: u32) -> bool { + return leftover(flags) and keep_reason(flags) == KEEP_NONE; +} + +/// The group a repository's row is shown in. +pub fn row_group(flags: u32) -> u32 { + if (removable(flags)) { return ROW_REMOVABLE; } + if (leftover(flags)) { return ROW_KEEP; } + if (has(flags, F_HEAVY) or has(flags, F_BUILD)) { return ROW_INFO; } + return ROW_CLEAN; +} + +/// Duplicates: of the clones of one remote, the keeper is the first that is neither an agent copy nor a +/// worktree, else the first; every other clone is a duplicate. `keeper_rank` orders the candidates (lower wins, +/// ties go to the first discovered). +pub fn keeper_rank(flags: u32) -> u32 { + if (has(flags, F_AGENT) == false and has(flags, F_WORKTREE) == false) { return 0; } + if (has(flags, F_AGENT) == false) { return 1; } + return 2; +} + +/// Whether a worktree takes part in duplicate finding: a worktree shares its repository's objects, so it is +/// not a second clone; it is a leftover only when it is an agent copy. +pub fn dup_candidate(flags: u32) -> bool { + return has(flags, F_WORKTREE) == false and has(flags, F_NO_REMOTE) == false; +} + +/// Which slots fill themselves: main clones on their own, worktrees and agent copies only through "+", +/// hidden ones never. +pub fn bind_rank(flags: u32) -> u32 { + if (has(flags, F_HIDDEN)) { return RANK_NEVER; } + if (has(flags, F_AGENT) or has(flags, F_WORKTREE)) { return RANK_PLUS; } + return RANK_AUTO; +} + +/// What a click on row i's button does while row `armed` is armed. +pub fn click_on_row(armed: u32, i: u32, flags: u32) -> u32 { + if (removable(flags) == false) { return CLICK_DISARM; } + if (armed == i) { return CLICK_REMOVE; } + return CLICK_ARM; +} + +/// The armed row after a click outcome. +pub fn after_click(outcome: u32, i: u32) -> u32 { + if (outcome == CLICK_ARM) { return i; } + return ARMED_NONE; +} + +/// How many DOCTOR rows fit under the summary in a panel of height sh below the title bar top t. +pub fn doc_rows(sh: f64, t: f64) -> u32 { + const room : f64 = sh - t - DOC_ROWS_Y - 8.0; + if (room < DOC_ROW_H) { return 0; } + return @as(u32, @intFromFloat(room / DOC_ROW_H)); +} + +/// Top of DOCTOR row r. +pub fn doc_row_top(t: f64, r: u32) -> f64 { + return t + DOC_ROWS_Y + @as(f64, @floatFromInt(r)) * DOC_ROW_H; +} + +/// Left edge of a row button in a panel whose column starts at l, w wide, with side padding pad. +pub fn doc_btn_x(l: f64, w: f64, pad: f64) -> f64 { + return l + w - pad - DOC_BTN_W; +} + +/// Whether (x, y) is on the button of shown row r. +pub fn on_doc_btn(x: f64, y: f64, l: f64, w: f64, pad: f64, t: f64, r: u32) -> bool { + const bx : f64 = doc_btn_x(l, w, pad); + const by : f64 = doc_row_top(t, r) + DOC_BTN_Y; + return x >= bx and x <= bx + DOC_BTN_W and y >= by and y <= by + DOC_BTN_H; +} + +/// Whether (x, y) is on "DOCTOR n" at the right end of the sidebar foot (side_w wide, foot_h tall, window sh). +pub fn on_doc_link(x: f64, y: f64, side_w: f64, sh: f64, foot_h: f64) -> bool { + return x >= side_w - 14.0 - DOC_LINK_W and x <= side_w - 8.0 and y >= sh - foot_h and y <= sh - 6.0; +} + +/// The first row shown after scrolling `off` rows into `total`, kept so the last page stays full. +pub fn doc_scroll(off: i64, total: u32, rows: u32) -> u32 { + if (off <= 0 or total <= rows) { return 0; } + const most : u32 = total - rows; + if (off >= @as(i64, most)) { return most; } + return @as(u32, @intCast(off)); +} + +test a_leftover_with_work_in_it_is_never_offered { + const clean_agent : u32 = F_CHECKED + F_PUSH_CHECKED + F_AGENT; + assert removable(clean_agent); + assert removable(clean_agent + F_DIRTY) == false; + assert keep_reason(clean_agent + F_DIRTY) == KEEP_DIRTY; + assert removable(clean_agent + F_UNPUSHED) == false; + assert keep_reason(clean_agent + F_UNPUSHED) == KEEP_UNPUSHED; + assert removable(clean_agent + F_STASH) == false; + assert removable(clean_agent + F_HAS_WORKTREES) == false; + assert removable(clean_agent + F_NESTS) == false; + assert removable(clean_agent + F_NO_REMOTE) == false; + // not checked yet, or only half checked: kept + assert keep_reason(F_AGENT) == KEEP_UNCHECKED; + assert removable(F_CHECKED + F_AGENT) == false; + // a clean main clone with nothing left over is not a suggestion + assert removable(F_CHECKED + F_PUSH_CHECKED) == false; + // a space in use is not a leftover + assert removable(clean_agent + F_BOUND) == false; +} + +test rows_are_grouped_removable_first { + const ok : u32 = F_CHECKED + F_PUSH_CHECKED; + assert row_group(ok + F_DUP) == ROW_REMOVABLE; + assert row_group(ok + F_DUP + F_DIRTY) == ROW_KEEP; + assert row_group(ok + F_HEAVY) == ROW_INFO; + assert row_group(ok) == ROW_CLEAN; +} + +test the_keeper_is_a_main_clone { + assert keeper_rank(0) < keeper_rank(F_WORKTREE); + assert keeper_rank(F_WORKTREE) < keeper_rank(F_AGENT); + assert dup_candidate(F_WORKTREE) == false; + assert dup_candidate(F_NO_REMOTE) == false; + assert dup_candidate(F_AGENT); +} + +test two_clicks_remove_one_row { + const ok : u32 = F_CHECKED + F_PUSH_CHECKED + F_REPEATED; + assert click_on_row(ARMED_NONE, 3, ok) == CLICK_ARM; + assert after_click(CLICK_ARM, 3) == 3; + assert click_on_row(3, 3, ok) == CLICK_REMOVE; + assert after_click(CLICK_REMOVE, 3) == ARMED_NONE; + // arming another row moves the arm, it does not remove + assert click_on_row(3, 5, ok) == CLICK_ARM; + assert click_on_row(3, 3, ok + F_DIRTY) == CLICK_DISARM; +} + +test the_walk_skips_caches_and_stays_shallow { + assert enters(ROOT_HOME, 1, false, false, false); + assert enters(ROOT_HOME, 1, true, false, false) == false; + assert enters(ROOT_HOME, 2, false, true, false) == false; + assert enters(ROOT_HOME, 2, false, true, true); + assert enters(ROOT_HOME, DEPTH_HOME + 1, false, false, false) == false; + assert enters(ROOT_TMP, DEPTH_TMP + 1, false, false, false) == false; + assert job_due(JOB_STATUS, 3); + assert job_due(JOB_SIZE, 3) == false; + assert job_due(JOB_SIZE, 4); + assert pass_due(RESCAN_FRAMES); + assert pass_due(RESCAN_FRAMES - 1) == false; + // a deleted repository vanishes within VANISH_FRAMES: one .git re-check a frame over LIST_MAX + assert LIST_MAX <= VANISH_FRAMES; +} + +test sizes_read_in_mb_then_gb { + assert size_in_gb(1023 * 1024) == false; + assert size_whole(1023 * 1024) == 1023; + assert size_in_gb(1024 * 1024); + assert size_whole(1536 * 1024) == 1; + assert size_tenth(1536 * 1024) == 5; + assert heavy(HEAVY_MB * 1024); + assert heavy(HEAVY_MB * 1024 - 1) == false; + assert build_heavy(BUILD_MB * 1024); +} + +test flags_and_words_line_up { + assert with_flag(0, F_DUP, true) == F_DUP; + assert with_flag(F_DUP, F_DUP, true) == F_DUP; + assert with_flag(F_DUP, F_DUP, false) == 0; + assert shows_word(F_DUP, 3); + assert shows_word(F_CHECKED, 0) == false; + assert shows_word(F_BOUND, 14) == false; + assert shows_word(F_HIDDEN, 15); + assert repeated(REPEAT_MIN); + assert repeated(REPEAT_MIN - 1) == false; + assert bind_rank(0) == RANK_AUTO; + assert bind_rank(F_WORKTREE) == RANK_PLUS; + assert bind_rank(F_HIDDEN) == RANK_NEVER; +} + +test doctor_rows_fit_the_window { + // a 540 pt window under the 35 pt workspace top holds eight rows of three lines + assert doc_rows(540.0, 35.0) == 8; + assert doc_row_top(35.0, 0) == 35.0 + DOC_ROWS_Y; + assert doc_scroll(0 - 3, 40, 10) == 0; + assert doc_scroll(5, 40, 10) == 5; + assert doc_scroll(99, 40, 10) == 30; + assert doc_scroll(5, 8, 10) == 0; +} + +test the_buttons_are_where_they_are_drawn { + // the Queen column of a 960 pt window: left 660, 300 wide, padding 16 + assert doc_btn_x(660.0, 300.0, 16.0) == 960.0 - 16.0 - DOC_BTN_W; + assert on_doc_btn(900.0, doc_row_top(35.0, 2) + DOC_BTN_Y + 5.0, 660.0, 300.0, 16.0, 35.0, 2); + assert on_doc_btn(900.0, doc_row_top(35.0, 2) + 2.0, 660.0, 300.0, 16.0, 35.0, 2) == false; + assert on_doc_btn(700.0, doc_row_top(35.0, 2) + DOC_BTN_Y + 5.0, 660.0, 300.0, 16.0, 35.0, 2) == false; + // the foot link and OPEN SOURCE T27 (14 .. under 134) do not overlap + assert on_doc_link(240.0, 520.0, 272.0, 540.0, 34.0); + assert on_doc_link(140.0, 520.0, 272.0, 540.0, 34.0) == false; + assert made_again(REPEAT_MIN, true); + assert made_again(REPEAT_MIN, false) == false; +} diff --git a/specs/t27os/frame_gate.t27 b/specs/t27os/frame_gate.t27 new file mode 100644 index 0000000000..798e9a5739 --- /dev/null +++ b/specs/t27os/frame_gate.t27 @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/frame_gate.t27 -- when the T27 OS window draws a frame, and how long it sleeps between frames. +; Audit, 2026-10-19 (epic #10782): the window drew the whole screen and handed a new image to its layer 30 +; times a second even when nothing on it moved. The loop now folds what the frame shows into one key and +; skips the draw when the key is the one already on screen; it still draws while something animates, and +; once every FG_FORCE frames so a change the key does not see (a reloaded store, a doctor scan) shows up +; within half a second. When nothing has moved for FG_QUIET frames and no terminal pane is live, it waits +; FG_IDLE_S for the next event instead of FRAME_S. A live pane keeps the short wait: its bytes come from a +; socket, not from an event, so a long wait would hold its output back. + +module T27osFrameGate; + +/// Frames between forced draws while the key stands still (0.5 s at 30 fps). +pub const FG_FORCE : u32 = 15; +/// Frames without a draw after which the loop counts as idle (1 s at 30 fps). +pub const FG_QUIET : u32 = 30; +/// The event wait while idle, in seconds. +pub const FG_IDLE_S : f64 = 0.5; + +/// Fold one value into the key: rotate left by 7 (constant shifts only), then xor. +pub fn fg_mix(h: u64, v: u64) -> u64 { + return ((h << 7) | (h >> 57)) ^ v; +} + +/// A flag as a key part. +pub fn fg_flag(b: bool) -> u64 { + if (b) { return 1; } + return 0; +} + +/// A fraction in [0, 1] (an animation level) as an integer the key can hold, to 1/4096. +pub fn fg_level(x: f64) -> u64 { + if (x <= 0.0) { return 0; } + if (x >= 1.0) { return 4096; } + return @as(u64, @intFromFloat(x * 4096.0)); +} + +/// A point coordinate as the cell it falls in: hover only changes what is drawn at cell granularity. +pub fn fg_cell(p: f64) -> u64 { + if (p <= 0.0) { return 0; } + return @as(u64, @intFromFloat(p / 4.0)); +} + +/// The mouse as a key part: its cell inside the scene, one fixed value outside it, so a pointer moving +/// over other apps does not count as a change to this window. +pub const FG_AWAY : u64 = 99999; +pub fn fg_mouse(p: f64, size: f64) -> u64 { + if (p < 0.0 or p > size) { return FG_AWAY; } + return fg_cell(p); +} + +/// Draw this frame? When the key changed, while something animates, or when FG_FORCE frames went undrawn. +pub fn fg_draw_due(key: u64, shown: u64, animating: bool, since: u32) -> bool { + return key != shown or animating or since >= FG_FORCE; +} + +/// Frames since the last draw, after this frame. +pub fn fg_since(since: u32, drew: bool) -> u32 { + if (drew) { return 0; } + if (since >= 9999) { return since; } + return since + 1; +} + +/// Frames without a changed key, after this frame: a forced draw of an unchanged key is still quiet. +pub fn fg_quiet(quiet: u32, changed: bool) -> u32 { + if (changed) { return 0; } + if (quiet >= 9999) { return quiet; } + return quiet + 1; +} + +/// How long the loop waits for an event: `frame_s` while busy or a pane is live, FG_IDLE_S once quiet. +pub fn fg_wait(quiet: u32, live: bool, frame_s: f64) -> f64 { + if (live) { return frame_s; } + if (quiet >= FG_QUIET) { return FG_IDLE_S; } + return frame_s; +} + +test the_key_changes_when_any_part_changes { + const a : u64 = fg_mix(fg_mix(fg_mix(0, 1), 2), 3); + const b : u64 = fg_mix(fg_mix(fg_mix(0, 1), 2), 4); + const c : u64 = fg_mix(fg_mix(fg_mix(0, 2), 1), 3); + assert a != b; + assert a != c; + assert a == fg_mix(fg_mix(fg_mix(0, 1), 2), 3); + // the rotation keeps the high bit: nothing falls off the top + assert fg_mix(9223372036854775808, 0) == 64; +} + +test levels_and_cells_quantise { + assert fg_flag(true) == 1; + assert fg_flag(false) == 0; + assert fg_level(0.0) == 0; + assert fg_level(-1.0) == 0; + assert fg_level(1.0) == 4096; + assert fg_level(0.5) == 2048; + assert fg_cell(-3.0) == 0; + assert fg_cell(3.9) == 0; + assert fg_cell(8.0) == 2; + assert fg_mouse(8.0, 100.0) == 2; + assert fg_mouse(-5.0, 100.0) == FG_AWAY; + assert fg_mouse(-500.0, 100.0) == FG_AWAY; + assert fg_mouse(101.0, 100.0) == FG_AWAY; +} + +test a_still_frame_is_skipped_until_forced { + assert fg_draw_due(5, 5, false, 0) == false; + assert fg_draw_due(5, 5, false, FG_FORCE - 1) == false; + assert fg_draw_due(5, 5, false, FG_FORCE); + assert fg_draw_due(6, 5, false, 0); + assert fg_draw_due(5, 5, true, 0); + assert fg_since(3, true) == 0; + assert fg_since(3, false) == 4; + assert fg_since(9999, false) == 9999; +} + +test the_wait_grows_only_when_quiet_and_no_pane_is_live { + assert fg_quiet(10, true) == 0; + assert fg_quiet(10, false) == 11; + assert fg_wait(0, false, 0.0333) == 0.0333; + assert fg_wait(FG_QUIET, false, 0.0333) == FG_IDLE_S; + assert fg_wait(FG_QUIET, true, 0.0333) == 0.0333; +} diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 new file mode 100644 index 0000000000..8575698d05 --- /dev/null +++ b/specs/t27os/glass_window.t27 @@ -0,0 +1,4241 @@ +// SPDX-License-Identifier: Apache-2.0 +; T27 OS glass window -- the 27-petal home as a native macOS window, written in t27 and built by t27b. +; No HTML, no Swift: t27b compiles this module to an arm64 Mach-O object (`t27b build --macho`), and +; `ld` links it against AppKit, CoreGraphics, CoreText and libobjc. Every call into the system goes +; through an import, `fn f(..) T { return @extern("sym"); }` (t27b extern boundary, #8946). +// +; It behaves as the trios app does (BrowserOS/trios TriosTabView + QueenUI MainView, read 2026-10-19): +; W window -- NSWindow 960 x 540 pt, transparent title bar, content = NSVisualEffectView, dark +; appearance, behind-window blending, black tint 0.30 (trios blackGlass nativeTint). +; T title -- trios title bar, 34 pt: the logo 17 x 14 (petal_menu style, 0.5 pt outline) + "TRINITY" = home, +; centred in the bar as one group (owner, 2026-10-19), fullscreen control on the right, +; 1 pt white 0.14 divider under it. The back chevron stays left, right of the traffic lights. +; H home -- the logo alone, centred in the area under the title bar at 0.63 of its height (0.8 of that in full screen), in +; petal_menu's style (idle black 0.22, hover white, 1 px white outline), flying in as +; petal_glass.t27 does. Hovering a petal shows the trios tooltip: a white 0.94 pill, +; corner 6, 30 pt above the petal centre, "NAME Cmd+1" in black semibold mono 13. +; S screen -- a petal tap (or Cmd/Option/Control + 1..9, as QueenMenuShortcutPolicy) swaps the +; home for a full-area screen with an opacity cross-fade of 0.25 s (8 frames). There is +; one bar: the title bar gains the back chevron before the logo (click, Escape or +; Cmd+0 = home) and the refresh arc left of the dock control; no second header. +; C chat -- the screen is trios' expanded chat workspace: a 272 pt sidebar (black 0.22) with the +; AGENTS label and the queen row (white crown, no other hue); tapping a cell opens that agent's +; chat, "+" at the right of the header opens one more (several chats per agent), and the x +; on a hovered AGENTS row closes that chat; "+" in the SPACES label opens a space, the x on a +; hovered space row closes it with its chats; the foot reads OPEN SOURCE T27 and opens +; the repository, +; a white 0.14 x 0.7 divider, then the content (black 0.14) with a 44 pt header -- the +; sidebar toggle (28 x 28, or Cmd+Shift+S; collapses at once, no animation) and the +; title -- and the 3 x 3 card slots in a readable column of at most 900 pt. +; K compact -- trios WindowManager + ChatWorkspaceLayout: the sidebar.right control left of fullscreen docks +; the window as the 400 pt panel on the right edge, full visible height (animated; again +; = restore). Below 760 pt the workspace is compact: no sidebar, an "OPEN DASHBOARD" bar +; (elevated 0.25, 1 pt border 0.3) over the cards; tapping it forces the dashboard, whose +; header gains "Close Dashboard" (rectangle.compress.vertical) right of the toggle. The +; narrow scene is the view point for point, the logo fits its width. (Letter tracking 0.5 +; of the bar text and the empty queen supervisor bar are not drawn.) +; Q queen -- Cmd+J or the QUEEN row opens her panel over any view (queen.t27): what the user is +; looking at, the last eight controls pressed, one suggestion behind Confirm / Dismiss, +; and GOD MODE (box filled = on): with the panel open and the user still 3 s she runs the +; suggestion herself and logs it; Cmd+J, Escape, the box or closing the panel stops it. +; T term -- a Claude Code chat shows a live terminal pane (term_pane.t27, vt.t27): the agent runs +; in the PTY server (pty_server.t27) reached over ~/.t27os/pty.sock, so it outlives the +; window; reopening the window re-finds the same pane. No server: offline, keys echo. +; D dock -- app icon: the logo white on a black squircle, drawn at launch. +; Not drawn (no server behind them yet): the Online dot, search, swarm and +; history lists, Cmd+K / Cmd+/ overlays, the formula in the tooltip. The Queen has no model +; behind her yet: her suggestion is a fixed rule over the sidebar. +; CoreGraphics rasterises (anti-aliased paths, CoreText lines) into a 4x bitmap that becomes the +; layer's contents. Epic gHashTag/t27#10782. + +module T27osGlassWindow; + +use t27os::layout::ROW_ICON_H; +use t27os::layout::ROW_GAP; +use t27os::layout::row_next; +use t27os::layout::centre_top; +use t27os::layout::baseline_for; +use t27os::layout::cap_mid; +use t27os::layout::same_line; +use t27os::agents::AGENT_NAMES; +use t27os::agents::AGENT_QUEEN; +use t27os::agents::S_STOPPED; +use t27os::windows::SPACE_NAMES; +use t27os::windows::SPACE_BRANCHES; +use t27os::windows::STATE_WORDS; +use t27os::windows::DOT_FAINT; +use t27os::windows::DOT_RING; +use t27os::windows::DOT_FILLED; +use t27os::windows::DOT_ALERT; +use t27os::windows::DOT_DIM; +use t27os::windows::GROUP_QUEEN; +use t27os::windows::GROUP_SPACES; +use t27os::windows::GROUP_AGENTS; +use t27os::windows::ROW_H; +use t27os::windows::AGENT_ROW_H; +use t27os::windows::ROW_NONE; +use t27os::windows::label_base; +use t27os::windows::row_top; +use t27os::windows::row_at; +use t27os::windows::SESSION_NONE; +use t27os::windows::session_count; +use t27os::windows::session_agent; +use t27os::windows::session_space; +use t27os::windows::add_session; +use t27os::windows::remove_session; +use t27os::windows::after_remove; +use t27os::windows::sessions_in; +use t27os::windows::session_at_row; +use t27os::windows::first_of; +use t27os::windows::chat_number; +use t27os::windows::new_chat_agent; +use t27os::windows::dot_for; +use t27os::windows::NEW_CHAT_DEFAULT; +use t27os::windows::SPACE_NONE; +use t27os::windows::SPACES_SEED; +use t27os::windows::SPACE_COUNT; +use t27os::windows::space_is_open; +use t27os::windows::spaces_shown; +use t27os::windows::space_at_row; +use t27os::windows::new_space; +use t27os::windows::add_space; +use t27os::windows::remove_space; +use t27os::windows::drop_sessions_in; +use t27os::windows::after_drop; +use t27os::windows::REPO_LINK_TEXT; +use t27os::windows::REPO_URL; +use t27os::windows::FOOT_H; +use t27os::queen::RING; +use t27os::queen::RING_NONE; +use t27os::queen::VIEW_HOME; +use t27os::queen::C_CELL; +use t27os::queen::C_NEW_CHAT; +use t27os::queen::C_CLOSE_CHAT; +use t27os::queen::C_NEW_SPACE; +use t27os::queen::C_CLOSE_SPACE; +use t27os::queen::C_REPO_LINK; +use t27os::queen::C_QUEEN_ROW; +use t27os::queen::C_SPACE_ROW; +use t27os::queen::C_AGENT_ROW; +use t27os::queen::C_SIDEBAR; +use t27os::queen::C_DASHBOARD; +use t27os::queen::C_QUEEN_KEY; +use t27os::queen::C_CONFIRM; +use t27os::queen::C_DISMISS; +use t27os::queen::C_GOD_ON; +use t27os::queen::C_GOD_OFF; +use t27os::queen::C_HOME; +use t27os::queen::C_WORLD; +use t27os::queen::C_NONE; +use t27os::queen::CONTROL_NAMES; +use t27os::queen::AGE_WORDS; +use t27os::queen::SUGGEST_WORDS; +use t27os::queen::ARG_AGENT; +use t27os::queen::ARG_SPACE; +use t27os::queen::A_NONE; +use t27os::queen::QP_W; +use t27os::queen::QP_PAD; +use t27os::queen::QP_GOD_Y; +use t27os::queen::QP_GOD_H; +use t27os::queen::QP_CTX_Y; +use t27os::queen::QP_SUG_Y; +use t27os::queen::QP_BTN_Y; +use t27os::queen::QP_BTN_W; +use t27os::queen::QP_BTN_H; +use t27os::queen::QP_BTN_GAP; +use t27os::queen::QP_RECENT_Y; +use t27os::queen::QP_LINE; +use t27os::queen::ev_pack; +use t27os::queen::ev_control; +use t27os::queen::ev_arg; +use t27os::queen::ev_sec; +use t27os::queen::arg_kind; +use t27os::queen::by_queen; +use t27os::queen::view_of; +use t27os::queen::ring_next; +use t27os::queen::ring_at; +use t27os::queen::age_word; +use t27os::queen::suggest; +use t27os::queen::shown_suggestion; +use t27os::queen::queen_control; +use t27os::queen::god_due; +use t27os::queen::on_queen_key; +use t27os::queen::act_sessions; +use t27os::queen::act_cur; +use t27os::queen::qp_left; +use t27os::queen::on_panel; +use t27os::queen::on_panel_close; +use t27os::queen::on_god; +use t27os::queen::on_confirm; +use t27os::queen::on_dismiss; +use t27os::queen::recent_rows; +use t27os::queen::FLAME_MID_R; +use t27os::queen::FLAME_MID_G; +use t27os::queen::FLAME_MID_B; +use t27os::queen::FLAME_CORE_R; +use t27os::queen::FLAME_CORE_G; +use t27os::queen::FLAME_CORE_B; +use t27os::queen::GLOW_CORE_W; +use t27os::queen::GLOW_BLUR; +use t27os::queen::flame_rgb; +use t27os::queen::core_alpha; +use t27os::queen::GLOW_W; +use t27os::queen::GLOW_BANDS; +use t27os::queen::glow_on; +use t27os::queen::glow_level; +use t27os::queen::glow_alpha; +use t27os::hot_reload::RELOAD_IDLE; +use t27os::hot_reload::reload_wait; +use t27os::hot_reload::reload_due; +use t27os::hot_reload::reload_poll; +use t27f::petal_menu::QX; +use t27f::petal_menu::QY; +use t27f::petal_menu::BOX_CX; +use t27f::petal_menu::BOX_CY; +use t27f::petal_menu::PETAL_COUNT; +use t27f::petal_menu::PETAL_NONE; +use t27f::petal_menu::STATE_HOME; +use t27f::petal_menu::CARD_SLOTS; +use t27f::petal_menu::CARD_GAP_PX; +use t27f::petal_menu::WORLD_NAMES; +use t27f::petal_menu::fit_permille; +use t27f::petal_menu::to_box; +use t27f::petal_menu::hit_px; +use t27f::petal_menu::fill; +use t27f::petal_menu::outline; +use t27f::petal_menu::on_tap; +use t27f::petal_menu::on_back; +use t27f::petal_menu::menu_open; +use t27f::petal_menu::card_x; +use t27f::petal_menu::card_y; +use t27os::doctor::ROOT_HOME; +use t27os::doctor::ROOT_TMP; +use t27os::doctor::ROOT_TMP_PATH; +use t27os::doctor::SCAN_SKIP; +use t27os::doctor::HIDDEN_PREFIX; +use t27os::doctor::HIDDEN_ALLOWED; +use t27os::doctor::SCAN_BUDGET; +use t27os::doctor::RESCAN_FRAMES; +use t27os::doctor::LIST_MAX; +use t27os::doctor::AGENT_ROOTS; +use t27os::doctor::STEM_PATTERN; +use t27os::doctor::JOB_STATUS; +use t27os::doctor::JOB_UNPUSHED; +use t27os::doctor::JOB_REMOTE; +use t27os::doctor::JOB_SIZE; +use t27os::doctor::JOB_BUILD; +use t27os::doctor::JOB_COUNT; +use t27os::doctor::JOB_LINES; +use t27os::doctor::JOB_SEP; +use t27os::doctor::ARG_SEP; +use t27os::doctor::PATH_MARK; +use t27os::doctor::JOB_SLOTS; +use t27os::doctor::ST_HEADER; +use t27os::doctor::ST_HEAD; +use t27os::doctor::ST_STASH; +use t27os::doctor::F_CHECKED; +use t27os::doctor::F_WORKTREE; +use t27os::doctor::F_AGENT; +use t27os::doctor::F_DUP; +use t27os::doctor::F_REPEATED; +use t27os::doctor::F_HEAVY; +use t27os::doctor::F_BUILD; +use t27os::doctor::F_DIRTY; +use t27os::doctor::F_UNPUSHED; +use t27os::doctor::F_STASH; +use t27os::doctor::F_HAS_WORKTREES; +use t27os::doctor::F_NESTS; +use t27os::doctor::F_NO_REMOTE; +use t27os::doctor::F_PUSH_CHECKED; +use t27os::doctor::F_BOUND; +use t27os::doctor::F_HIDDEN; +use t27os::doctor::FLAG_BITS; +use t27os::doctor::FLAG_WORDS; +use t27os::doctor::KEEP_NONE; +use t27os::doctor::KEEP_WORDS; +use t27os::doctor::ROW_REMOVABLE; +use t27os::doctor::ROW_GROUPS; +use t27os::doctor::ARMED_NONE; +use t27os::doctor::CLICK_REMOVE; +use t27os::doctor::RANK_AUTO; +use t27os::doctor::RANK_PLUS; +use t27os::doctor::SPACES_AUTO; +use t27os::doctor::SEED_PATHS; +use t27os::doctor::DOC_HEAD_Y; +use t27os::doctor::DOC_SUM_Y; +use t27os::doctor::DOC_ROWS_Y; +use t27os::doctor::DOC_ROW_H; +use t27os::doctor::DOC_BTN_W; +use t27os::doctor::DOC_BTN_H; +use t27os::doctor::DOC_BTN_Y; +use t27os::doctor::DOC_LINK_W; +use t27os::doctor::DOC_WORDS; +use t27os::doctor::DL_TITLE; +use t27os::doctor::DL_REPOS; +use t27os::doctor::DL_CHECKED; +use t27os::doctor::DL_CAN_GO; +use t27os::doctor::DL_SCANNING; +use t27os::doctor::DL_WATCHING; +use t27os::doctor::DL_REMOVE; +use t27os::doctor::DL_CONFIRM; +use t27os::doctor::DL_NOTHING; +use t27os::doctor::DL_MB; +use t27os::doctor::DL_GB; +use t27os::doctor::DL_FOOT; +use t27os::doctor::has; +use t27os::doctor::with_flag; +use t27os::doctor::shows_word; +use t27os::doctor::enters; +use t27os::doctor::pass_done; +use t27os::doctor::pass_due; +use t27os::doctor::job_due; +use t27os::doctor::made_again; +use t27os::doctor::heavy; +use t27os::doctor::build_heavy; +use t27os::doctor::size_in_gb; +use t27os::doctor::size_whole; +use t27os::doctor::size_tenth; +use t27os::doctor::keep_reason; +use t27os::doctor::leftover; +use t27os::doctor::removable; +use t27os::doctor::row_group; +use t27os::doctor::keeper_rank; +use t27os::doctor::dup_candidate; +use t27os::doctor::bind_rank; +use t27os::doctor::click_on_row; +use t27os::doctor::after_click; +use t27os::doctor::doc_rows; +use t27os::doctor::doc_row_top; +use t27os::doctor::doc_btn_x; +use t27os::doctor::on_doc_btn; +use t27os::doctor::on_doc_link; +use t27os::doctor::doc_scroll; +use t27os::vt::ST_X; +use t27os::vt::ST_Y; +use t27os::vt::ST_COLS; +use t27os::vt::ST_ROWS; +use t27os::vt::BLANK; +use t27os::vt::vt_init; +use t27os::vt::vt_feed; +use t27os::vt::vt_feed_all; +use t27os::term_pane::TERM_BIN; +use t27os::term_pane::TP_FONT; +use t27os::term_pane::TP_CELL_W; +use t27os::term_pane::TP_LINE_H; +use t27os::term_pane::TP_BASE; +use t27os::term_pane::TP_PATH_MAX; +use t27os::term_pane::TP_LINE_TITLE; +use t27os::term_pane::TP_LINE_CWD; +use t27os::term_pane::TP_LINE_OFFLINE; +use t27os::term_pane::TP_PROMPT; +use t27os::term_pane::PANE_OFFLINE; +use t27os::term_pane::PANE_LIVE; +use t27os::term_pane::PANE_NONE; +use t27os::term_pane::opens_pane; +use t27os::term_pane::tp_cols; +use t27os::term_pane::tp_rows; +use t27os::term_pane::tp_resize_due; +use t27os::term_pane::tp_settle; +use t27os::term_pane::tp_size_due; +use t27os::frame_gate::fg_mix; +use t27os::frame_gate::fg_flag; +use t27os::frame_gate::fg_level; +use t27os::frame_gate::fg_cell; +use t27os::frame_gate::fg_draw_due; +use t27os::frame_gate::fg_since; +use t27os::frame_gate::fg_quiet; +use t27os::frame_gate::fg_wait; +use t27os::frame_gate::fg_mouse; +use t27os::text_cache::tc_style; +use t27os::text_cache::tc_flush; +use t27os::text_cache::tc_room; +use t27os::text_cache::tc_cut; +use t27os::text_cache::TC_FREE; +use t27os::window_keep::wk_flags; +use t27os::window_keep::wk_key; +use t27os::window_keep::wk_still; +use t27os::window_keep::wk_due; +use t27os::window_keep::wk_restore; +use t27os::term_pane::tp_cell_x; +use t27os::term_pane::tp_cell_y; +use t27os::term_pane::tp_cursor_col; +use t27os::term_pane::tp_cursor_on; +use t27os::term_pane::tp_reset_due; +use t27os::term_pane::tp_utf8; +use t27os::term_pane::tp_key_bytes; +use t27os::term_pane::tp_echo; +use t27os::term_pane::TP_DATA_AT; +use t27os::term_pane::tp_pick_pane; +use t27os::term_pane::tp_ordinal; +use t27os::term_pane::tp_spawned; +use t27os::term_pane::tp_data_len; +use t27os::term_pane::tp_read_more; +use t27os::term_pane::tp_retry_due; +use t27os::pty_client::client_connect; +use t27os::pty_client::client_list; +use t27os::pty_client::client_spawn; +use t27os::pty_client::client_attach; +use t27os::pty_client::client_keys; +use t27os::pty_client::client_read; +use t27os::pty_client::client_resize; +use t27os::pty_client::reply_cursor; +use t27os::pty_sys::sys_strlen; +use t27os::pty_sys::sys_peek; +use t27os::pty_sys::sys_close; + +// ---- W window ---- + +pub const W_PT : f64 = 960.0; +pub const H_PT : f64 = 540.0; +pub const RETINA : f64 = 4.0; +/// titled | closable | miniaturizable | resizable | fullSizeContentView (the green button goes full screen) +pub const STYLE : u64 = 32783; +/// NSWindowCollectionBehaviorFullScreenPrimary +pub const FULL_SCREEN_PRIMARY : u64 = 128; +/// NSViewWidthSizable | NSViewHeightSizable +pub const SIZABLE : u64 = 18; +pub const BACKING_BUFFERED : u64 = 2; +/// NSVisualEffectMaterialHUDWindow, NSVisualEffectBlendingModeBehindWindow, state active +pub const MATERIAL : u64 = 13; +pub const BLEND_BEHIND : u64 = 0; +pub const STATE_ACTIVE : u64 = 1; +pub const UTF8 : u32 = 134217984; +/// kCGImageAlphaPremultipliedLast +pub const ALPHA_PREMUL_LAST : u32 = 1; +/// kCGBlendModeNormal, kCGBlendModeClear +pub const BLEND_NORMAL : u32 = 0; +pub const BLEND_CLEAR : u32 = 16; +pub const EV_LEFT_DOWN : u64 = 1; +pub const EV_KEY_DOWN : u64 = 10; +pub const EV_SCROLL : u64 = 22; +pub const FRAME_S : f64 = 0.0333333333333333; + +// ---- keys (virtual key codes, modifier flag bits) ---- + +pub const KEY_S : u64 = 1; +pub const KEY_F : u64 = 3; +pub const KEY_ESCAPE : u64 = 53; +pub const MOD_SHIFT : u64 = 131072; +pub const MOD_CONTROL : u64 = 262144; +pub const MOD_OPTION : u64 = 524288; +pub const MOD_COMMAND : u64 = 1048576; +pub const NO_DIGIT : u32 = 10; + +// ---- trios blackGlass values ---- + +pub const TINT_BLACK : f64 = 0.30; +pub const BORDER_WHITE : f64 = 0.14; +pub const SURFACE_BLACK : f64 = 0.46; +pub const ELEVATED_BLACK : f64 = 0.58; +pub const SIDEBAR_BLACK : f64 = 0.22; +pub const CONTENT_BLACK : f64 = 0.14; +pub const HIGHLIGHT_WHITE : f64 = 0.08; +pub const MUTED_WHITE : f64 = 0.62; +pub const NAV_SHADE : f64 = 0.72; +pub const TIP_WHITE : f64 = 0.94; +/// F. The screen is black and white (t27#10831 FR-006): the queen crown is white at the old alpha. +pub const CROWN_WHITE : f64 = 0.70; +/// NSFontWeight +pub const WEIGHT_MEDIUM : f64 = 0.23; +pub const WEIGHT_SEMIBOLD : f64 = 0.3; +pub const WEIGHT_BOLD : f64 = 0.4; + +// ---- T title bar, H home, S screen, C chat (trios layout, points) ---- + +pub const TITLE_H : f64 = 34.0; +pub const AREA_TOP : f64 = 35.0; +pub const HOME_X : f64 = 84.0; +/// T. Full screen: the logo's left edge on the back chevron's tip (x 13.5). +pub const FULL_HOME_X : f64 = 13.0; +pub const TITLE_LOGO_LINE : f64 = 0.5; +/// T. The back chevron: CHEV_W wide, ROW_ICON_H tall like the logo (layout.t27 places the row). +pub const CHEV_W : f64 = 4.5; +/// T. The title-bar logo is 1.2 of the row height (owner, 2026-10-19: 20% larger in the header). +pub const TITLE_LOGO_SCALE : f64 = 1.2; +/// H. Brand mark per size: petal white; the large mark parts petals with a black cut, the small one +/// with a finer cut so it stays white at 14 pt; cards are honeycomb cells HEX_GAP apart. +pub const LOGO_CUT : f64 = 0.5; +pub const LOGO_SEAL : f64 = 0.18; +/// H. The centre hole of the mark in box milli-units (inner vertices of petals 20, 24, 18/26 in QX/QY), +/// grown for the small mark. +pub const HOLE_L : f64 = 268400.0; +pub const HOLE_R : f64 = 325600.0; +pub const HOLE_TOP : f64 = 223600.0; +pub const HOLE_AX : f64 = 297100.0; +pub const HOLE_AY : f64 = 272700.0; +pub const HOLE_GROW : f64 = 1.4; +pub const SQRT3 : f64 = 1.7320508075688772; +pub const HEX_GAP : f64 = 10.0; +pub const LOGO_WHITE : f64 = 0.96; +pub const HOME_BTN_W : f64 = 92.0; +pub const FULL_INSET : f64 = 36.0; +/// K. the dock-panel control sits one 28 pt step left of the fullscreen control. +pub const DOCK_STEP : f64 = 28.0; +pub const FULL_PT : f64 = 20.0; +pub const LOGO_SHARE : f64 = 0.63; +/// H. In full screen the home logo is drawn 20% smaller (NSWindowStyleMaskFullScreen = 1 << 14). +pub const FULL_LOGO : f64 = 0.8; +pub const MASK_FULL_SCREEN : u64 = 16384; +pub const TIP_RISE : f64 = 30.0; +pub const TIP_H : f64 = 31.0; +/// S. opacity cross-fade 0.25 s at 30 fps +pub const WAVE_STEP : f64 = 0.125; +/// S. One bar: while a screen is up the back chevron takes the logo's place and the logo moves NAV_SHIFT right. +pub const NAV_SHIFT : f64 = 12.5; +/// S. The back chevron's hit box: the chevron alone now that the logo sits in the middle of the bar. +pub const BACK_W : f64 = 40.0; +pub const WS_TOP : f64 = 35.0; +pub const SIDE_W : f64 = 272.0; +pub const HEADER_H : f64 = 44.0; +pub const TOGGLE_PT : f64 = 28.0; +pub const READABLE_W : f64 = 900.0; +pub const BODY_PAD : f64 = 24.0; +/// K. trios ChatWorkspaceLayout: below 760 pt the workspace is compact (no sidebar, the dashboard bar); +/// WindowManager docks the panel 400 pt wide on the right edge of the screen, full height. +pub const COMPACT_BELOW : f64 = 760.0; +pub const PANEL_W : f64 = 400.0; +pub const BAR_H : f64 = 26.0; +/// K. the compact bitmap holds a scene up to 760 x 1300 pt at RETINA. +pub const CW_MAX : f64 = 760.0; +pub const CH_MAX : f64 = 1300.0; +/// F. the wide bitmap holds a scene up to 1920 x 1200 pt at the display's own 2x: one device pixel per +/// pixel, so the 0.5 pt hairlines below land on single pixels. +pub const SW_MAX : f64 = 1920.0; +pub const SH_MAX : f64 = 1200.0; +pub const WIDE_RETINA : f64 = 2.0; +/// F. In full screen the behind-window blur sits over the desktop wallpaper and reads grey; the tint +/// rises to 0.55: black glass that still lets the wallpaper show through. +pub const TINT_FULL : f64 = 0.55; +/// F. Owner, 2026-10-19: the background is black (#000000). It was glass (0.60 over the behind-window +/// blur), but the blurred wallpaper read as a warm glow along the edges; the edges are pure black now +/// and the only edge light is the blue gas-flame GOD MODE glow (queen.t27, the FR-006 exception). +pub const BACK_BLACK : f64 = 1.0; +/// F. The strict chat palette: near-black surfaces, 0.5 pt hairlines, small radii, outline-only cards. +pub const HAIR : f64 = 0.5; +pub const LINE_WHITE : f64 = 0.12; +pub const STRICT_SIDE : f64 = 0.45; +pub const STRICT_CONTENT : f64 = 0.35; +pub const STRICT_R : f64 = 4.0; +pub const STRICT_CARD_FILL : f64 = 0.02; +pub const STRICT_CARD_EDGE : f64 = 0.11; +pub const STRICT_HOVER_FILL : f64 = 0.05; +pub const STRICT_HOVER_EDGE : f64 = 0.28; +/// A. The cell whose task is open, and its sidebar row: lit above hover, still white only. +pub const TASK_FILL : f64 = 0.10; +pub const TASK_EDGE : f64 = 0.55; +pub const TEXT_WHITE : f64 = 0.88; +pub const LABEL_WHITE : f64 = 0.50; +pub const WS_EXPANDED : u32 = 0; +pub const WS_COMPACT : u32 = 1; +pub const WS_DASHBOARD : u32 = 2; +pub const CARD_R : f64 = 12.0; +pub const CARD_FILL : f64 = 0.06; +pub const CARD_EDGE : f64 = 0.10; +pub const CASCADE : f64 = 0.06; +pub const CARD_HOVER_FILL : f64 = 0.12; +pub const CARD_HOVER_EDGE : f64 = 0.30; +pub const CARD_HOVER_LIFT : f64 = 3.0; +/// D. Dock icon: 512 px canvas, 412 px black squircle (the macOS grid), logo 250 px tall. +pub const ICON_SIZE : f64 = 512.0; +pub const ICON_TILE : f64 = 412.0; +pub const ICON_LOGO_PX : f64 = 250.0; + +// ---- A assembly (LogoAnimation, as in petal_glass.t27) ---- + +pub const ANIM_FRAMES : u32 = 75; +pub const START_OFF : f64 = 800.0; +pub const PHASE1_FRAMES : u32 = 52; +pub const PHASE1_RATE : f64 = 4.5; +pub const V_HANDOFF : f64 = 0.4; +pub const SPRING_K : f64 = 28.0; +pub const SPRING_DAMP : f64 = 0.86; +pub const DT : f64 = 0.0333333333333333; +pub const TAU : f64 = 6.28318530717958647; + +// ---- the text kit: objects built once in main, indexed here ---- + +pub const K_FONT : u32 = 0; +pub const K_MONO_SEL : u32 = 1; +pub const K_SYS_SEL : u32 = 2; +pub const K_KEY_FONT : u32 = 3; +pub const K_KEY_CTX : u32 = 4; +pub const K_YES : u32 = 5; +pub const K_DICT : u32 = 6; +pub const K_DICT_SEL : u32 = 7; +pub const K_ATTR : u32 = 8; +pub const K_ALLOC : u32 = 9; +pub const K_INIT : u32 = 10; +pub const K_RELEASE : u32 = 11; +pub const K_NAMES : u32 = 12; +pub const K_AT : u32 = 13; +pub const K_TRINITY : u32 = 14; +pub const K_QUEEN : u32 = 16; +pub const K_CMD : u32 = 17; +pub const K_OPT : u32 = 18; +pub const K_CTRL : u32 = 19; +pub const K_DIGITS : u32 = 20; +pub const K_OPEN_DASH : u32 = 21; +pub const K_AGENTS : u32 = 22; +pub const K_AGENT_NAMES : u32 = 23; +pub const K_SPACES : u32 = 24; +pub const K_QUEEN_LBL : u32 = 25; +pub const K_SPACE_NAMES : u32 = 26; +pub const K_BRANCHES : u32 = 27; +pub const K_STATE_WORDS : u32 = 28; +pub const K_EMPTY : u32 = 29; +pub const K_CONTROLS : u32 = 30; +pub const K_AGES : u32 = 31; +pub const K_SUGGEST : u32 = 32; +pub const K_QP : u32 = 33; +pub const K_REPO : u32 = 34; +/// M11. The line cache (text_cache.t27): style number -> (text -> CTLine), and what it needs. +pub const K_CACHE : u32 = 35; +pub const K_GET : u32 = 36; +pub const K_PUT : u32 = 37; +pub const K_COUNT : u32 = 38; +pub const K_FLUSH : u32 = 39; +pub const K_NUM_CLS : u32 = 40; +pub const K_NUM : u32 = 41; +pub const K_ELL : u32 = 42; +pub const K_MDICT : u32 = 43; +pub const K_NEW : u32 = 44; +/// M11. Where a sidebar label must stop: short of the row's close x and status dot. +pub const TEXT_RIGHT : f64 = SIDE_W - 38.0; +/// Q. The Queen panel's words, in K_QP order. +pub const QP_WORDS : str = "QUEEN,LOOKING AT,SUGGESTION,RECENT,Confirm,Dismiss,GOD MODE OFF,GOD MODE ON,Home,no chat open,she suggests and you confirm,acting on her own - Cmd+J stops,Nothing yet - Cmd+J opens this panel anywhere"; +pub const QL_QUEEN : u32 = 0; +pub const QL_LOOKING : u32 = 1; +pub const QL_SUGGESTION : u32 = 2; +pub const QL_RECENT : u32 = 3; +pub const QL_CONFIRM : u32 = 4; +pub const QL_DISMISS : u32 = 5; +pub const QL_GOD_OFF : u32 = 6; +pub const QL_GOD_ON : u32 = 7; +pub const QL_HOME : u32 = 8; +pub const QL_NO_CHAT : u32 = 9; +pub const QL_SUB_OFF : u32 = 10; +pub const QL_SUB_ON : u32 = 11; +pub const QL_EMPTY : u32 = 12; +/// Q. Cmd+J opens the Queen panel from any view. +pub const KEY_J : u64 = 38; + +// ---- imports: Foundation, the ObjC runtime, AppKit through objc_msgSend, CoreGraphics, CoreText ---- + +fn cf_bytes(alloc: u64, bytes: []const u8, n: usize, enc: u32, ext: bool) -> u64 { return @extern("CFStringCreateWithBytes"); } +fn cf_release(o: u64) -> void { @extern("CFRelease"); } +fn unix_time(t: u64) -> u64 { return @extern("time"); } +fn class_named(name: u64) -> u64 { return @extern("NSClassFromString"); } +fn sel_named(name: u64) -> u64 { return @extern("NSSelectorFromString"); } +fn pool_push() -> u64 { return @extern("objc_autoreleasePoolPush"); } +fn pool_pop(p: u64) -> void { @extern("objc_autoreleasePoolPop"); } +fn msg0(o: u64, s: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg1(o: u64, s: u64, a: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg2(o: u64, s: u64, a: u64, b: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg3(o: u64, s: u64, a: u64, b: u64, c: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg4(o: u64, s: u64, a: u64, b: u64, c: u64, d: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg5(o: u64, s: u64, a: u64, b: u64, c: u64, d: u64, e: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg_real(o: u64, s: u64) -> f64 { return @extern("objc_msgSend"); } +fn home_dir() -> u64 { return @extern("NSHomeDirectory"); } +fn msg_flag(o: u64, s: u64, a: bool) -> u64 { return @extern("objc_msgSend"); } +fn msg_test(o: u64, s: u64) -> bool { return @extern("objc_msgSend"); } +fn msg_test1(o: u64, s: u64, a: u64) -> bool { return @extern("objc_msgSend"); } +fn exec_self(path: u64, argv: [2]u64) -> i32 { return @extern("execv"); } +fn msg_f(o: u64, s: u64, t: f64) -> u64 { return @extern("objc_msgSend"); } +fn msg_ff(o: u64, s: u64, a: f64, b: f64) -> u64 { return @extern("objc_msgSend"); } +fn msg_dict(o: u64, s: u64, vals: [2]u64, keys: [2]u64, n: u64) -> u64 { return @extern("objc_msgSend"); } +fn msg_rect(o: u64, s: u64, x: f64, y: f64, w: f64, h: f64) -> u64 { return @extern("objc_msgSend"); } +fn msg_window(o: u64, s: u64, x: f64, y: f64, w: f64, h: f64, style: u64, backing: u64, later: bool) -> u64 { return @extern("objc_msgSend"); } +fn msg_event(o: u64, s: u64, mask: u64, until: u64, mode: u64, dequeue: bool) -> u64 { return @extern("objc_msgSend"); } +fn msg_point(o: u64, s: u64, out: [2]f64) -> void { @extern("objc_msgSend"); } +fn msg_frame(o: u64, s: u64, out: [4]f64) -> void { @extern("objc_msgSend"); } +fn msg_image(o: u64, s: u64, img: u64, w: f64, h: f64) -> u64 { return @extern("objc_msgSend"); } +fn msg_set_frame(o: u64, s: u64, x: f64, y: f64, w: f64, h: f64, show: bool, animate: bool) -> u64 { return @extern("objc_msgSend"); } +fn msg_cstr(o: u64, s: u64, buf: []u8, max: u64, enc: u64) -> bool { return @extern("objc_msgSend"); } +fn msg_char(o: u64, s: u64, i: u64) -> u64 { return @extern("objc_msgSend"); } + +fn cg_rgb() -> u64 { return @extern("CGColorSpaceCreateDeviceRGB"); } +fn cg_bitmap(data: u64, w: usize, h: usize, bits: usize, row: usize, space: u64, info: u32) -> u64 { return @extern("CGBitmapContextCreate"); } +fn cg_image(c: u64) -> u64 { return @extern("CGBitmapContextCreateImage"); } +fn cg_image_release(i: u64) -> void { @extern("CGImageRelease"); } +fn cg_crop(i: u64, x: f64, y: f64, w: f64, h: f64) -> u64 { return @extern("CGImageCreateWithImageInRect"); } +fn cg_translate(c: u64, x: f64, y: f64) -> void { @extern("CGContextTranslateCTM"); } +fn cg_scale(c: u64, x: f64, y: f64) -> void { @extern("CGContextScaleCTM"); } +fn cg_clear(c: u64, x: f64, y: f64, w: f64, h: f64) -> void { @extern("CGContextClearRect"); } +fn cg_fill_rect(c: u64, x: f64, y: f64, w: f64, h: f64) -> void { @extern("CGContextFillRect"); } +fn cg_fill_rgba(c: u64, r: f64, g: f64, b: f64, a: f64) -> void { @extern("CGContextSetRGBFillColor"); } +fn cg_stroke_rgba(c: u64, r: f64, g: f64, b: f64, a: f64) -> void { @extern("CGContextSetRGBStrokeColor"); } +fn cg_line_width(c: u64, w: f64) -> void { @extern("CGContextSetLineWidth"); } +fn cg_move(c: u64, x: f64, y: f64) -> void { @extern("CGContextMoveToPoint"); } +fn cg_line(c: u64, x: f64, y: f64) -> void { @extern("CGContextAddLineToPoint"); } +fn cg_arc(c: u64, x: f64, y: f64, r: f64, a0: f64, a1: f64, cw: u32) -> void { @extern("CGContextAddArc"); } +fn cg_close(c: u64) -> void { @extern("CGContextClosePath"); } +fn cg_fill_path(c: u64) -> void { @extern("CGContextFillPath"); } +fn cg_stroke_path(c: u64) -> void { @extern("CGContextStrokePath"); } +fn cg_round_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64, t: u64) -> u64 { return @extern("CGPathCreateWithRoundedRect"); } +fn cg_add_path(c: u64, p: u64) -> void { @extern("CGContextAddPath"); } +fn cg_path_release(p: u64) -> void { @extern("CGPathRelease"); } +fn cg_color(r: f64, g: f64, b: f64, a: f64) -> u64 { return @extern("CGColorCreateGenericRGB"); } +fn cg_color_release(col: u64) -> void { @extern("CGColorRelease"); } +fn cg_shadow(c: u64, ox: f64, oy: f64, blur: f64, col: u64) -> void { @extern("CGContextSetShadowWithColor"); } +fn cg_save(c: u64) -> void { @extern("CGContextSaveGState"); } +fn cg_restore(c: u64) -> void { @extern("CGContextRestoreGState"); } +fn cg_blend(c: u64, mode: u32) -> void { @extern("CGContextSetBlendMode"); } +fn cg_text_pos(c: u64, x: f64, y: f64) -> void { @extern("CGContextSetTextPosition"); } +fn ct_line(attr: u64) -> u64 { return @extern("CTLineCreateWithAttributedString"); } +fn ct_width(line: u64, ascent: u64, descent: u64, leading: u64) -> f64 { return @extern("CTLineGetTypographicBounds"); } +fn ct_draw(line: u64, c: u64) -> void { @extern("CTLineDraw"); } +fn ct_truncate(line: u64, width: f64, kind: u32, token: u64) -> u64 { return @extern("CTLineCreateTruncatedLine"); } +fn msg_chars(o: u64, s: u64, buf: [1]u64, n: u64) -> u64 { return @extern("objc_msgSend"); } + +// ---- small helpers ---- + +/// The product name the header and the window title show: "TRINITY S", U+00B3 SUPERSCRIPT THREE, "AI", as UTF-8. +/// Source stays ASCII (L3) and a t27b string literal keeps `\xC2` as four characters, so the superscript three +/// goes in as its two UTF-8 bytes; returns the length written. +pub const BRAND_HEAD : str = "TRINITY S"; +pub const BRAND_TAIL : str = "AI"; +pub const BRAND_LEN : usize = 13; + +pub fn brand(out: []u8) -> usize { + var n : usize = 0; + var i : usize = 0; + while (i < BRAND_HEAD.len) { + out[n] = BRAND_HEAD[i]; + n = n + 1; + i = i + 1; + } + out[n] = 0xC2; + out[n + 1] = 0xB3; + n = n + 2; + i = 0; + while (i < BRAND_TAIL.len) { + out[n] = BRAND_TAIL[i]; + n = n + 1; + i = i + 1; + } + return n; +} + +fn cf(s: []const u8) -> u64 { + return cf_bytes(0, s, s.len, UTF8, false); +} + +fn cls(s: []const u8) -> u64 { + return class_named(cf(s)); +} + +fn sel(s: []const u8) -> u64 { + return sel_named(cf(s)); +} + +/// Channel k of a 0xAARRGGBB value in 0..1: 0 red, 1 green, 2 blue, 3 alpha. +pub fn chan(c: u32, k: u32) -> f64 { + var v : u32 = c % 256; + if (k == 3) { v = c / 16777216; } + if (k == 0) { v = (c / 65536) % 256; } + if (k == 1) { v = (c / 256) % 256; } + return @as(f64, @floatFromInt(v)) / 255.0; +} + +pub fn clamp01(x: f64) -> f64 { + if (x < 0.0) { return 0.0; } + if (x > 1.0) { return 1.0; } + return x; +} + +/// S. One step of the screen's opacity towards shown (1) or gone (0). +pub fn wave(a: f64, open: bool) -> f64 { + if (open) { return clamp01(a + WAVE_STEP); } + return clamp01(a - WAVE_STEP); +} + +/// Viewport pixels per box milli-unit x 1000, from petal_menu's fit. +pub fn scale() -> f64 { + return @as(f64, @floatFromInt(fit_permille(960, 540))) / 1000.0; +} + +pub fn cen_x(p: u32) -> f64 { + const i : u32 = p * 4; + return @as(f64, @floatFromInt(QX[i] + QX[i + 1] + QX[i + 2] + QX[i + 3])) / 4.0; +} + +pub fn cen_y(p: u32) -> f64 { + const i : u32 = p * 4; + return @as(f64, @floatFromInt(QY[i] + QY[i + 1] + QY[i + 2] + QY[i + 3])) / 4.0; +} + +/// A. Unit direction a block flies in from: box centre -> its centroid ((1, 0) for a block on the centre). +pub fn dir(p: u32, axis: u32) -> f64 { + const dx : f64 = cen_x(p) - @as(f64, @floatFromInt(BOX_CX)); + const dy : f64 = cen_y(p) - @as(f64, @floatFromInt(BOX_CY)); + const len : f64 = @sqrt(dx * dx + dy * dy); + if (len <= 100.0) { + if (axis == 0) { return 1.0; } + return 0.0; + } + if (axis == 0) { return dx / len; } + return dy / len; +} + +/// A. How far (SVG units) every block still is from home after `frame` updates at 30 fps. +pub fn anim_off(frame: u32) -> f64 { + var off : f64 = START_OFF; + var v : f64 = 0.0; + var f : u32 = 0; + while (f < frame and f < ANIM_FRAMES) { + if (f < PHASE1_FRAMES) { + off = off - off * PHASE1_RATE * DT; + v = 0.0 - off * V_HANDOFF; + } else { + v = v + (0.0 - off) * SPRING_K * DT; + v = v * SPRING_DAMP; + off = off + v * DT * 60.0; + } + f = f + 1; + } + if (frame >= ANIM_FRAMES) { return 0.0; } + return off; +} + +/// L. Extent of the logo on one axis (0 x, 1 y) in box milli-units: lowest or highest vertex. +pub fn logo_edge(axis: u32, high: bool) -> f64 { + var best : i64 = QX[0]; + if (axis == 1) { best = QY[0]; } + var i : u32 = 1; + while (i < PETAL_COUNT * 4) { + var v : i64 = QX[i]; + if (axis == 1) { v = QY[i]; } + if (high and v > best) { best = v; } + if (high == false and v < best) { best = v; } + i = i + 1; + } + return @as(f64, @floatFromInt(best)); +} + +/// L. Size factor that makes the logo `px` points tall (1.0 = the size petal_menu's fit gives). +pub fn logo_k_for(px: f64) -> f64 { + return px / ((logo_edge(1, true) - logo_edge(1, false)) / 1000.0 * scale()); +} + +/// H. The home logo: 0.63 of the area under the title bar, centred in it (TriangleLogo's scale). The +/// scene is sw x sh points; in the narrow panel the width binds, so the logo keeps 0.63 of that too. +pub fn home_px(z: f64, sw: f64, sh: f64) -> f64 { + var room : f64 = sh - AREA_TOP; + const by_w : f64 = sw * (logo_edge(1, true) - logo_edge(1, false)) / (logo_edge(0, true) - logo_edge(0, false)); + if (by_w < room) { room = by_w; } + return LOGO_SHARE * room * z; +} + +pub fn home_k(z: f64, sw: f64, sh: f64) -> f64 { + return logo_k_for(home_px(z, sw, sh)); +} + +/// H. Centre (for to_pt) that puts the logo's own middle at the middle of the area, on one axis. +pub fn logo_at(axis: u32, k: f64, mid_pt: f64) -> f64 { + var c : f64 = @as(f64, @floatFromInt(BOX_CX)); + if (axis == 1) { c = @as(f64, @floatFromInt(BOX_CY)); } + const mid : f64 = 0.5 * (logo_edge(axis, false) + logo_edge(axis, true)); + return mid_pt - (mid - c) / 1000.0 * scale() * k; +} + +pub fn home_c(axis: u32, z: f64, sw: f64, sh: f64) -> f64 { + if (axis == 0) { return logo_at(0, home_k(z, sw, sh), 0.5 * sw); } + return logo_at(1, home_k(z, sw, sh), 0.5 * (AREA_TOP + sh)); +} + +/// A box coordinate in milli-units to a window point on one axis (0 x, 1 y; y grows down), for a logo +/// centred at `cen` and sized k. +pub fn to_pt(m: f64, axis: u32, cen: f64, k: f64) -> f64 { + const s : f64 = scale() * k; + if (axis == 0) { return cen + (m - @as(f64, @floatFromInt(BOX_CX))) / 1000.0 * s; } + return cen + (m - @as(f64, @floatFromInt(BOX_CY))) / 1000.0 * s; +} + +/// The mouse in the frame petal_menu's hit_px expects (logo centred in 960 x 540, size 1). +pub fn virt(v: f64, axis: u32, z: f64, sw: f64, sh: f64) -> f64 { + var home : f64 = 0.5 * W_PT; + if (axis == 1) { home = 0.5 * H_PT; } + return home + (v - home_c(axis, z, sw, sh)) / home_k(z, sw, sh); +} + +/// H. Where the tooltip pill centres for petal p: 30 pt above the petal's centre. +pub fn tip_x(p: u32, z: f64, sw: f64, sh: f64) -> f64 { + return to_pt(cen_x(p), 0, home_c(0, z, sw, sh), home_k(z, sw, sh)); +} + +pub fn tip_y(p: u32, z: f64, sw: f64, sh: f64) -> f64 { + return to_pt(cen_y(p), 1, home_c(1, z, sw, sh), home_k(z, sw, sh)) - TIP_RISE; +} + +fn in_box(x: f64, y: f64, l: f64, t: f64, w: f64, h: f64) -> bool { + return x >= l and x <= l + w and y >= t and y <= t + h; +} + +/// T. Title-bar controls: logo + TRINITY (home), fullscreen. +/// T. Left edge of the title-bar logo: right of the traffic lights in a window, on the back chevron's +/// line in full screen (no traffic lights there). +pub fn home_x(full: bool) -> f64 { + if (full) { return FULL_HOME_X; } + return HOME_X; +} + +/// T. Width of the title-bar logo drawn ROW_ICON_H tall, from the mark's own extent. +pub fn title_logo_w() -> f64 { + return (logo_edge(0, true) - logo_edge(0, false)) / 1000.0 * scale() * logo_k_for(ROW_ICON_H * TITLE_LOGO_SCALE); +} + +/// T. Left edge of a group w wide centred in a bar sw wide: the logo + TRINITY sit in the middle. +pub fn brand_left(sw: f64, w: f64) -> f64 { + return 0.5 * (sw - w); +} + +pub fn on_home_button(x: f64, y: f64, sw: f64) -> bool { + return in_box(x, y, brand_left(sw, HOME_BTN_W), 0.0, HOME_BTN_W, TITLE_H); +} + +pub fn full_x(sw: f64) -> f64 { + return sw - FULL_INSET; +} + +pub fn on_full_button(x: f64, y: f64, sw: f64) -> bool { + return in_box(x, y, full_x(sw), 0.5 * (TITLE_H - FULL_PT), FULL_PT, FULL_PT); +} + +/// K. The dock-panel control (sidebar.right): docks the window as the 400 pt trios panel, or undocks it. +pub fn dock_x(sw: f64) -> f64 { + return full_x(sw) - DOCK_STEP; +} + +pub fn on_dock_button(x: f64, y: f64, sw: f64) -> bool { + return in_box(x, y, dock_x(sw), 0.5 * (TITLE_H - FULL_PT), FULL_PT, FULL_PT); +} + +/// K. ChatWorkspaceLayout.metrics: expanded from 760 pt; below it compact, or the dashboard the user +/// forced open from the compact bar (isDashboardExpanded). +pub fn workspace(sw: f64, dash: bool) -> u32 { + if (sw >= COMPACT_BELOW) { return WS_EXPANDED; } + if (dash) { return WS_DASHBOARD; } + return WS_COMPACT; +} + +/// K. sidebarWidth: 272 only when expanded and not collapsed, else 0 (trios keeps a zero-width +/// sidebar and its divider in the forced dashboard). +pub fn side_w(ws: u32, collapsed: bool) -> f64 { + if (ws == WS_EXPANDED and collapsed == false) { return SIDE_W; } + return 0.0; +} + +/// K. The "OPEN DASHBOARD" bar across the top of the compact workspace. +pub fn on_open_dash(x: f64, y: f64, sw: f64, ws: u32) -> bool { + return ws == WS_COMPACT and in_box(x, y, 0.0, WS_TOP, sw, BAR_H); +} + +/// S. The back chevron + logo + TRINITY in the one title bar. +pub fn on_nav_back(x: f64, y: f64, full: bool) -> bool { + return in_box(x, y, home_x(full), 0.0, BACK_W, TITLE_H); +} + +/// C. Left edge of the chat content: right of the sidebar and its divider, or 0 when collapsed. +pub fn content_x(ws: u32, collapsed: bool) -> f64 { + if (ws == WS_COMPACT or collapsed) { return 0.0; } + return side_w(ws, collapsed) + 1.0; +} + +/// W. True when x is over the sidebar's rows (expanded workspace, sidebar shown). +pub fn in_side(x: f64, ws: u32, collapsed: bool) -> bool { + return ws == WS_EXPANDED and collapsed == false and x >= 12.0 and x <= SIDE_W - 12.0; +} + +pub fn on_side_toggle(x: f64, y: f64, ws: u32, collapsed: bool) -> bool { + if (ws == WS_COMPACT) { return false; } + return in_box(x, y, content_x(ws, collapsed) + 14.0, WS_TOP + 0.5 * (HEADER_H - TOGGLE_PT), TOGGLE_PT, TOGGLE_PT); +} + +/// K. "Close Dashboard" (rectangle.compress.vertical), right of the toggle, only in the forced dashboard. +pub fn close_x(ws: u32, collapsed: bool) -> f64 { + return content_x(ws, collapsed) + 14.0 + TOGGLE_PT + 12.0; +} + +pub fn on_close_dash(x: f64, y: f64, ws: u32, collapsed: bool) -> bool { + return ws == WS_DASHBOARD and in_box(x, y, close_x(ws, collapsed), WS_TOP + 0.5 * (HEADER_H - TOGGLE_PT), TOGGLE_PT, TOGGLE_PT); +} + +/// N. "+" (new chat), at the right end of the header, the same 28 pt box as the toggle. +pub fn new_chat_x(sw: f64) -> f64 { + return sw - 14.0 - TOGGLE_PT; +} + +pub fn on_new_chat(x: f64, y: f64, sw: f64, ws: u32) -> bool { + if (ws == WS_COMPACT) { return false; } + return in_box(x, y, new_chat_x(sw), WS_TOP + 0.5 * (HEADER_H - TOGGLE_PT), TOGGLE_PT, TOGGLE_PT); +} + +/// N. The close x of an AGENTS row whose top is `top`: a 16 pt box centred where the dot sits. +pub fn on_row_close(x: f64, y: f64, top: f64) -> bool { + return in_box(x, y, SIDE_W - 34.0, top + 11.0, 16.0, 16.0); +} + +/// W. "+" in the SPACES label line: a 16 pt box at the right end, centred 3.5 pt above the baseline. +pub fn on_space_plus(x: f64, y: f64) -> bool { + return in_box(x, y, SIDE_W - 34.0, label_base(WS_TOP, GROUP_SPACES, 0) - 11.5, 16.0, 16.0); +} + +/// W. The close x of a SPACES row whose top is `top`, at the right end where the AGENTS x sits. +pub fn on_space_close(x: f64, y: f64, top: f64) -> bool { + return in_box(x, y, SIDE_W - 34.0, top + 6.0, 16.0, 16.0); +} + +/// W. OPEN SOURCE T27 at the foot of the sidebar, the strip FOOT_H above the bottom edge, left of DOCTOR. +pub fn on_repo_link(x: f64, y: f64, sh: f64, ws: u32, collapsed: bool) -> bool { + return in_side(x, ws, collapsed) and y >= sh - FOOT_H and y <= sh - 6.0 and on_doc_link(x, y, SIDE_W, sh, FOOT_H) == false; +} + +/// C. The readable column the cards live in: at most 900 pt, centred, at least 24 pt from each side. +pub fn body_w(sw: f64, ws: u32, collapsed: bool) -> f64 { + const room : f64 = sw - content_x(ws, collapsed) - 2.0 * BODY_PAD; + if (room > READABLE_W) { return READABLE_W; } + return room; +} + +pub fn body_left(sw: f64, ws: u32, collapsed: bool) -> f64 { + return content_x(ws, collapsed) + 0.5 * (sw - content_x(ws, collapsed) - body_w(sw, ws, collapsed)); +} + +/// C/K. Under the 44 pt header and its divider, or under the 26 pt dashboard bar when compact. +pub fn body_top(ws: u32) -> f64 { + if (ws == WS_COMPACT) { return WS_TOP + BAR_H + BODY_PAD; } + return WS_TOP + HEADER_H + 1.0 + BODY_PAD; +} + +/// K. In the narrow panel the 3 x 3 slots keep the shape they have in the wide window (top-aligned), +/// instead of stretching down the whole 400 pt column. +pub fn body_h(sw: f64, sh: f64, ws: u32, collapsed: bool) -> f64 { + const room : f64 = sh - body_top(ws) - BODY_PAD; + if (ws == WS_EXPANDED) { return room; } + const keep : f64 = body_w(sw, ws, collapsed) * wide_shape(); + if (keep < room) { return keep; } + return room; +} + +/// K. Height over width of the card column in the wide window with the sidebar collapsed. +pub fn wide_shape() -> f64 { + return (H_PT - body_top(WS_EXPANDED) - BODY_PAD) / READABLE_W; +} + +/// C. Honeycomb: the nine cards are pointy-top hexagons, three rows of three, the middle row shifted +/// half a cell right. The cell radius (centre to corner) that fits a w x h body with HEX_GAP between cells. +pub fn hex_r(w: f64, h: f64) -> f64 { + const byw : f64 = (w - 2.5 * HEX_GAP) / (3.5 * SQRT3); + const byh : f64 = (h - 2.0 * HEX_GAP) / 5.0; + if (byw < byh) { return byw; } + return byh; +} + +/// C. Centre of honeycomb cell k on one axis (0 x, 1 y) in a body at (l, t) of w x h, the comb centred. +pub fn hex_c(k: u32, axis: u32, l: f64, t: f64, w: f64, h: f64) -> f64 { + const r : f64 = hex_r(w, h); + const cw : f64 = SQRT3 * r; + const col : f64 = @as(f64, @floatFromInt(k % 3)); + const row : f64 = @as(f64, @floatFromInt(k / 3)); + if (axis == 0) { + var x : f64 = l + 0.5 * (w - (3.5 * cw + 2.5 * HEX_GAP)) + 0.5 * cw + col * (cw + HEX_GAP); + if (k / 3 == 1) { x = x + 0.5 * (cw + HEX_GAP); } + return x; + } + return t + 0.5 * (h - (5.0 * r + 2.0 * HEX_GAP)) + r + row * (1.5 * r + HEX_GAP); +} + +/// C. Is (x, y) inside the pointy-top hexagon of radius r centred at (cx, cy)? +pub fn in_hex(x: f64, y: f64, cx: f64, cy: f64, r: f64) -> bool { + const dx : f64 = @abs(x - cx); + const dy : f64 = @abs(y - cy); + return dx <= 0.5 * SQRT3 * r and dy <= r - dx / SQRT3; +} + +/// C. The pointy-top hexagon path of radius r centred at (cx, cy). +fn hex_path(c: u64, cx: f64, cy: f64, r: f64) -> void { + const hw : f64 = 0.5 * SQRT3 * r; + cg_move(c, cx, cy - r); + cg_line(c, cx + hw, cy - 0.5 * r); + cg_line(c, cx + hw, cy + 0.5 * r); + cg_line(c, cx, cy + r); + cg_line(c, cx - hw, cy + 0.5 * r); + cg_line(c, cx - hw, cy - 0.5 * r); + cg_close(c); +} + +/// C. The card slot under (x, y), CARD_SLOTS when none. +pub fn card_at(x: f64, y: f64, sw: f64, sh: f64, ws: u32, collapsed: bool) -> u32 { + const l : f64 = body_left(sw, ws, collapsed); + const t : f64 = body_top(ws); + const w : f64 = body_w(sw, ws, collapsed); + const h : f64 = body_h(sw, sh, ws, collapsed); + var k : u32 = 0; + while (k < CARD_SLOTS) { + if (in_hex(x, y, hex_c(k, 0, l, t, w, h), hex_c(k, 1, l, t, w, h), hex_r(w, h))) { return k; } + k = k + 1; + } + return CARD_SLOTS; +} + +/// C. Cards arrive one after another: slot k shows CASCADE of the fade later than slot k - 1. +pub fn card_alpha(ma: f64, k: u32) -> f64 { + return clamp01((ma - CASCADE * @as(f64, @floatFromInt(k))) / (1.0 - CASCADE * 8.0)); +} + +/// S. Modifier bit test on NSEvent.modifierFlags. +pub fn has_mod(flags: u64, bit: u64) -> bool { + return (flags / bit) % 2 == 1; +} + +/// S. The digit a virtual key code types (0..9), NO_DIGIT for any other key. +pub fn digit_of(key: u64) -> u32 { + if (key == 29) { return 0; } + if (key == 18) { return 1; } + if (key == 19) { return 2; } + if (key == 20) { return 3; } + if (key == 21) { return 4; } + if (key == 23) { return 5; } + if (key == 22) { return 6; } + if (key == 26) { return 7; } + if (key == 28) { return 8; } + if (key == 25) { return 9; } + return NO_DIGIT; +} + +/// S. QueenMenuShortcutPolicy: exactly Cmd + 1..9 opens petals 0..8, Option 9..17, Control 18..26. +pub fn petal_for_key(digit: u32, flags: u64) -> u32 { + if (digit < 1 or digit > 9) { return PETAL_NONE; } + const cmd : bool = has_mod(flags, MOD_COMMAND); + const opt : bool = has_mod(flags, MOD_OPTION); + const ctl : bool = has_mod(flags, MOD_CONTROL); + if (has_mod(flags, MOD_SHIFT)) { return PETAL_NONE; } + if (cmd and opt == false and ctl == false) { return digit - 1; } + if (opt and cmd == false and ctl == false) { return 9 + digit - 1; } + if (ctl and cmd == false and opt == false) { return 18 + digit - 1; } + return PETAL_NONE; +} + +/// D. Dock icon centre on one axis for to_pt, so the logo's own middle (not the box centre) sits mid-icon. +pub fn icon_cen(axis: u32) -> f64 { + return logo_at(axis, logo_k_for(ICON_LOGO_PX), 0.5 * ICON_SIZE); +} + +/// K. The scene for a view of w x h points: the view itself, point for point, so text keeps its real +/// size and stays sharp in full screen (no upscaled 960 x 540 stage, no letterbox bands); capped by the +/// bitmap it draws into. axis 0 width, 1 height. +pub fn scene(w: f64, h: f64, axis: u32) -> f64 { + if (w >= COMPACT_BELOW) { + if (axis == 0) { + if (w > SW_MAX) { return SW_MAX; } + return w; + } + if (h > SH_MAX) { return SH_MAX; } + return h; + } + if (axis == 0) { return w; } + if (h > CH_MAX) { return CH_MAX; } + return h; +} + +/// Fit of the sw x sh scene into a view of w x h points (aspect kept, centred): the scale. +pub fn fit(w: f64, h: f64, sw: f64, sh: f64) -> f64 { + const sx : f64 = w / sw; + const sy : f64 = h / sh; + if (sx < sy) { return sx; } + return sy; +} + +/// A view point (y up, as AppKit reports it) to a scene point (y down) on one axis. +pub fn view_to_scene(v: f64, w: f64, h: f64, axis: u32, sw: f64, sh: f64) -> f64 { + const s : f64 = fit(w, h, sw, sh); + if (axis == 0) { return (v - 0.5 * (w - sw * s)) / s; } + return sh - (v - 0.5 * (h - sh * s)) / s; +} + +// ---- text (CoreText lines, coloured by the context's fill colour) ---- + +fn mk_line(kit: [48]u64, s: u64, size: f64, weight: f64, mono: bool) -> u64 { + var font : u64 = 0; + if (mono) { + font = msg_ff(kit[K_FONT], kit[K_MONO_SEL], size, weight); + } else { + font = msg_ff(kit[K_FONT], kit[K_SYS_SEL], size, weight); + } + var keys : [2]u64 = undefined; + var vals : [2]u64 = undefined; + keys[0] = kit[K_KEY_FONT]; + vals[0] = font; + keys[1] = kit[K_KEY_CTX]; + vals[1] = kit[K_YES]; + const attrs : u64 = msg_dict(kit[K_DICT], kit[K_DICT_SEL], vals, keys, 2); + const astr : u64 = msg2(msg0(kit[K_ATTR], kit[K_ALLOC]), kit[K_INIT], s, attrs); + const line : u64 = ct_line(astr); + msg0(astr, kit[K_RELEASE]); + return line; +} + +/// M11. The laid-out line for s in this style, cut with an ellipsis to `room` when it overflows +/// (TC_FREE: never cut). Borrowed from the cache (text_cache.t27): the caller must not release it. +fn cached_line(kit: [48]u64, s: u64, size: f64, weight: f64, mono: bool, room: f64) -> u64 { + const key : u64 = msg1(kit[K_NUM_CLS], kit[K_NUM], tc_style(size, weight, mono, room)); + var table : u64 = msg1(kit[K_CACHE], kit[K_GET], key); + if (table == 0) { + table = msg0(kit[K_MDICT], kit[K_NEW]); + msg2(kit[K_CACHE], kit[K_PUT], table, key); + msg0(table, kit[K_RELEASE]); + } + const hit : u64 = msg1(table, kit[K_GET], s); + if (hit != 0) { return hit; } + if (tc_flush(msg0(table, kit[K_COUNT]))) { msg0(table, kit[K_FLUSH]); } + var line : u64 = mk_line(kit, s, size, weight, mono); + if (tc_cut(ct_width(line, 0, 0, 0), room)) { + const tok : u64 = mk_line(kit, kit[K_ELL], size, weight, mono); + const cut : u64 = ct_truncate(line, room, 1, tok); + cf_release(tok); + if (cut != 0) { + cf_release(line); + line = cut; + } + } + // the table copies the text as its key and retains the line; ours goes + msg2(table, kit[K_PUT], line, s); + cf_release(line); + return line; +} + +fn text_w(kit: [48]u64, s: u64, size: f64, weight: f64, mono: bool) -> f64 { + return ct_width(cached_line(kit, s, size, weight, mono, TC_FREE), 0, 0, 0); +} + +fn draw_line(c: u64, line: u64, x: f64, base: f64) -> f64 { + cg_save(c); + cg_translate(c, x, base); + cg_scale(c, 1.0, -1.0); + cg_text_pos(c, 0.0, 0.0); + ct_draw(line, c); + cg_restore(c); + return ct_width(line, 0, 0, 0); +} + +/// Draws s with its baseline at (x, base) in the current fill colour; returns its width. +fn draw_text(c: u64, kit: [48]u64, s: u64, x: f64, base: f64, size: f64, weight: f64, mono: bool) -> f64 { + return draw_line(c, cached_line(kit, s, size, weight, mono, TC_FREE), x, base); +} + +/// M11. draw_text, cut with an ellipsis where it would pass `right`; draws nothing when under +/// TC_MIN_ROOM is left. Returns the width drawn. +fn draw_text_fit(c: u64, kit: [48]u64, s: u64, x: f64, base: f64, size: f64, weight: f64, mono: bool, right: f64) -> f64 { + const room : f64 = tc_room(x, right); + if (room <= 0.0) { return 0.0; } + return draw_line(c, cached_line(kit, s, size, weight, mono, room), x, base); +} + +fn world(kit: [48]u64, p: u32) -> u64 { + return msg1(kit[K_NAMES], kit[K_AT], @as(u64, p)); +} + +// ---- drawing ---- + +fn round_rect(c: u64, x: f64, y: f64, w: f64, h: f64, r: f64) -> void { + const p : u64 = cg_round_rect(x, y, w, h, r, r, 0); + cg_add_path(c, p); + cg_path_release(p); +} + +fn set_fill(c: u64, argb: u32, k: f64) -> void { + cg_fill_rgba(c, chan(argb, 0), chan(argb, 1), chan(argb, 2), chan(argb, 3) * k); +} + +fn petal_path(c: u64, p: u32, cx: f64, cy: f64, k: f64, ox: f64, oy: f64) -> void { + var v : u32 = 0; + while (v < 4) { + const x : f64 = to_pt(@as(f64, @floatFromInt(QX[p * 4 + v])) + ox, 0, cx, k); + const y : f64 = to_pt(@as(f64, @floatFromInt(QY[p * 4 + v])) + oy, 1, cy, k); + if (v == 0) { cg_move(c, x, y); } else { cg_line(c, x, y); } + v = v + 1; + } + cg_close(c); +} + +/// D. The Dock icon: the logo white on a black rounded square, drawn into a 512 px bitmap (y down). +fn draw_app_icon(c: u64) -> void { + const m : f64 = 0.5 * (ICON_SIZE - ICON_TILE); + round_rect(c, m, m, ICON_TILE, ICON_TILE, ICON_TILE * 0.225); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_fill_path(c); + const k : f64 = logo_k_for(ICON_LOGO_PX); + cg_line_width(c, 3.0); + var p : u32 = 0; + while (p < PETAL_COUNT) { + petal_path(c, p, icon_cen(0), icon_cen(1), k, 0.0, 0.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 1.0); + cg_fill_path(c); + petal_path(c, p, icon_cen(0), icon_cen(1), k, 0.0, 0.0); + cg_stroke_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_stroke_path(c); + p = p + 1; + } +} + +/// H. The large mark (home): white petals parted by a thin black cut, the centre left open; +/// the hovered or open petal takes its realm colour centred at (cx, cy), sized k, at alpha a. `off` is the fly-in distance (milli-units). +fn draw_logo(c: u64, cx: f64, cy: f64, k: f64, off: f64, state: u32, hov: u32, a: f64) -> void { + cg_line_width(c, LOGO_CUT); + var p : u32 = 0; + while (p < PETAL_COUNT) { + const ox : f64 = dir(p, 0) * off; + const oy : f64 = dir(p, 1) * off; + petal_path(c, p, cx, cy, k, ox, oy); + if (p == hov or p == state) { set_fill(c, fill(p, state, hov), a); } else { cg_fill_rgba(c, 1.0, 1.0, 1.0, LOGO_WHITE * a); } + cg_fill_path(c); + petal_path(c, p, cx, cy, k, ox, oy); + cg_stroke_rgba(c, 0.0, 0.0, 0.0, a); + cg_stroke_path(c); + p = p + 1; + } +} + +/// T. The small mark (title bar): white petals with a fine black cut, LOGO_SEAL thin (the large cut +/// would swallow a 14 pt mark), and the centre hole cut back in, hairline-thin at this size. +fn draw_template_logo(c: u64, cx: f64, cy: f64, k: f64) -> void { + cg_line_width(c, LOGO_SEAL); + var p : u32 = 0; + while (p < PETAL_COUNT) { + petal_path(c, p, cx, cy, k, 0.0, 0.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 1.0); + cg_fill_path(c); + petal_path(c, p, cx, cy, k, 0.0, 0.0); + cg_stroke_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_stroke_path(c); + p = p + 1; + } + // the centre hole, cut back in black and grown about its centroid so it survives at 14 pt + const gx : f64 = (HOLE_L + HOLE_R + HOLE_AX) / 3.0; + const gy : f64 = (2.0 * HOLE_TOP + HOLE_AY) / 3.0; + cg_move(c, to_pt(gx + (HOLE_L - gx) * HOLE_GROW, 0, cx, k), to_pt(gy + (HOLE_TOP - gy) * HOLE_GROW, 1, cy, k)); + cg_line(c, to_pt(gx + (HOLE_R - gx) * HOLE_GROW, 0, cx, k), to_pt(gy + (HOLE_TOP - gy) * HOLE_GROW, 1, cy, k)); + cg_line(c, to_pt(gx + (HOLE_AX - gx) * HOLE_GROW, 0, cx, k), to_pt(gy + (HOLE_AY - gy) * HOLE_GROW, 1, cy, k)); + cg_close(c); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_fill_path(c); +} + +/// T. The one title bar: (screen up) the back chevron, the template logo + TRINITY (home), (screen up) +/// the refresh arc, the dock-panel and fullscreen controls, the divider. The world name is not repeated +/// here: the content header already says it. +fn draw_titlebar(c: u64, kit: [48]u64, sw: f64, full: bool, ma: f64) -> void { + const mid : f64 = 0.5 * TITLE_H; + if (ma > 0.0) { + const bx : f64 = home_x(full); + cg_line_width(c, 1.2); + const ct : f64 = centre_top(mid, ROW_ICON_H); + cg_move(c, bx + CHEV_W, ct); + cg_line(c, bx, mid); + cg_line(c, bx + CHEV_W, ct + ROW_ICON_H); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * ma); + cg_stroke_path(c); + // refresh: an outline-only arrow arc one step left of the dock control + const rx : f64 = dock_x(sw) - DOCK_STEP + 0.5 * FULL_PT; + cg_line_width(c, 1.1); + cg_arc(c, rx, mid, 4.5, 0.5, TAU - 0.4, 0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * ma); + cg_stroke_path(c); + cg_move(c, rx + 4.9, mid - 4.2); + cg_line(c, rx + 4.5, mid - 0.9); + cg_line(c, rx + 1.4, mid - 2.0); + cg_stroke_path(c); + } + const k : f64 = logo_k_for(ROW_ICON_H * TITLE_LOGO_SCALE); + const lw : f64 = title_logo_w(); + const tw : f64 = text_w(kit, kit[K_TRINITY], 11.0, WEIGHT_SEMIBOLD, false); + const hx : f64 = brand_left(sw, row_next(lw, tw, ROW_GAP)); + draw_template_logo(c, logo_at(0, k, hx + 0.5 * lw), logo_at(1, k, mid), k); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, kit[K_TRINITY], row_next(hx, lw, ROW_GAP), baseline_for(mid, 11.0), 11.0, WEIGHT_SEMIBOLD, false); + // arrow.up.left.and.arrow.down.right, grokMuted + const x : f64 = full_x(sw) + 4.0; + const y : f64 = 0.5 * (TITLE_H - FULL_PT) + 4.0; + cg_line_width(c, 1.4); + cg_move(c, x, y); + cg_line(c, x + 12.0, y + 12.0); + cg_move(c, x, y + 5.0); + cg_line(c, x, y); + cg_line(c, x + 5.0, y); + cg_move(c, x + 12.0, y + 7.0); + cg_line(c, x + 12.0, y + 12.0); + cg_line(c, x + 7.0, y + 12.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + cg_stroke_path(c); + // K. sidebar.right, grokMuted: the dock-panel control + const dx : f64 = dock_x(sw) + 2.0; + const dy : f64 = 0.5 * (TITLE_H - 13.0); + cg_line_width(c, 1.3); + round_rect(c, dx, dy, 16.0, 13.0, 3.0); + cg_move(c, dx + 10.5, dy); + cg_line(c, dx + 10.5, dy + 13.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, 0.0, TITLE_H, sw, HAIR); +} + +/// H. The hover tooltip for petal p: "NAME Cmd+1" on a white pill. +fn draw_tooltip(c: u64, kit: [48]u64, p: u32, a: f64, z: f64, sw: f64, sh: f64) -> void { + const name : u64 = world(kit, p); + var pre : u64 = kit[K_CMD]; + if (p >= 9) { pre = kit[K_OPT]; } + if (p >= 18) { pre = kit[K_CTRL]; } + const dig : u64 = msg1(kit[K_DIGITS], kit[K_AT], @as(u64, p % 9)); + const wn : f64 = text_w(kit, name, 13.0, WEIGHT_SEMIBOLD, true); + const wp : f64 = text_w(kit, pre, 13.0, WEIGHT_SEMIBOLD, true); + const wd : f64 = text_w(kit, dig, 13.0, WEIGHT_SEMIBOLD, true); + const gap : f64 = 16.0; + const w : f64 = wn + gap + wp + wd + 24.0; + const x : f64 = tip_x(p, z, sw, sh) - 0.5 * w; + const y : f64 = tip_y(p, z, sw, sh) - 0.5 * TIP_H; + round_rect(c, x, y, w, TIP_H, 6.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TIP_WHITE * a); + cg_fill_path(c); + cg_fill_rgba(c, 0.0, 0.0, 0.0, a); + const base : f64 = tip_y(p, z, sw, sh) + 4.6; + draw_text(c, kit, name, x + 12.0, base, 13.0, WEIGHT_SEMIBOLD, true); + draw_text(c, kit, pre, x + 12.0 + wn + gap, base, 13.0, WEIGHT_SEMIBOLD, true); + draw_text(c, kit, dig, x + 12.0 + wn + gap + wp, base, 13.0, WEIGHT_SEMIBOLD, true); +} + +/// W. A session-state dot centred at (x, y): the shape carries the state (black and white only). +fn draw_dot(c: u64, d: u32, x: f64, y: f64, a: f64) -> void { + cg_line_width(c, 1.0); + if (d == DOT_FILLED) { + cg_arc(c, x, y, 3.5, 0.0, TAU, 0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 0.90 * a); + cg_fill_path(c); + } else if (d == DOT_ALERT) { + cg_arc(c, x, y, 2.5, 0.0, TAU, 0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 0.95 * a); + cg_fill_path(c); + cg_arc(c, x, y, 5.0, 0.0, TAU, 0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, 0.90 * a); + cg_stroke_path(c); + } else if (d == DOT_DIM) { + cg_arc(c, x, y, 3.5, 0.0, TAU, 0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 0.35 * a); + cg_fill_path(c); + } else { + var e : f64 = 0.30; + if (d == DOT_RING) { e = 0.75; } + cg_arc(c, x, y, 3.0, 0.0, TAU, 0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, e * a); + cg_stroke_path(c); + } +} + +/// N. A close x centred on (x, y); a hairline box behind it while the pointer is on it. +fn draw_close_x(c: u64, x: f64, y: f64, lit: bool, a: f64) -> void { + if (lit) { + round_rect(c, x - 8.0, y - 8.0, 16.0, 16.0, 4.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE * a); + cg_fill_path(c); + } + cg_line_width(c, 1.3); + cg_move(c, x - 3.5, y - 3.5); + cg_line(c, x + 3.5, y + 3.5); + cg_move(c, x + 3.5, y - 3.5); + cg_line(c, x - 3.5, y + 3.5); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); +} + +/// W. A sidebar row's ground: lit when selected, a faint fill under the mouse, nothing otherwise. +fn side_row(c: u64, y: f64, h: f64, selected: bool, hover: bool, a: f64) -> void { + var f : f64 = 0.0; + if (hover) { f = STRICT_HOVER_FILL; } + if (selected) { f = TASK_FILL; } + if (f > 0.0) { + round_rect(c, 12.0, y, SIDE_W - 24.0, h, STRICT_R); + cg_fill_rgba(c, 1.0, 1.0, 1.0, f * a); + cg_fill_path(c); + } +} + +/// C. Chat workspace: sidebar (unless collapsed), divider, content with header, divider and cards. +/// K. Compact (ws 1): no sidebar, the OPEN DASHBOARD bar instead of the header. Forced dashboard (ws 2): +/// the expanded layout with a zero-width sidebar and the Close Dashboard button in the header. +fn draw_chat(c: u64, kit: [48]u64, shown: u32, task: u32, space: u32, sp: u32, ss: u64, cur: u32, a: f64, collapsed: bool, mx: f64, my: f64, sw: f64, sh: f64, ws: u32, dlabel: u64, dlit: bool) -> void { + const cx : f64 = content_x(ws, collapsed); + if (ws == WS_DASHBOARD and collapsed == false) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE * a); + cg_fill_rect(c, 0.0, WS_TOP, HAIR, sh - WS_TOP); + } + if (ws == WS_EXPANDED and collapsed == false) { + cg_fill_rgba(c, 0.0, 0.0, 0.0, STRICT_SIDE * a); + cg_fill_rect(c, 0.0, WS_TOP, SIDE_W, sh - WS_TOP); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE * a); + cg_fill_rect(c, SIDE_W, WS_TOP, HAIR, sh - WS_TOP); + // W. three groups (windows.t27): QUEEN, SPACES, AGENTS -- herdr's sidebar with the Queen on top + const n : u32 = spaces_shown(sp); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + draw_text(c, kit, kit[K_QUEEN_LBL], 14.0, label_base(WS_TOP, GROUP_QUEEN, n), 9.5, WEIGHT_SEMIBOLD, false); + const qy : f64 = row_top(WS_TOP, GROUP_QUEEN, n, 0); + side_row(c, qy, ROW_H, task == AGENT_QUEEN, in_box(mx, my, 12.0, qy, SIDE_W - 24.0, ROW_H), a); + cg_move(c, 22.0, qy + 19.0); + cg_line(c, 22.0, qy + 11.0); + cg_line(c, 25.0, qy + 14.0); + cg_line(c, 27.5, qy + 9.0); + cg_line(c, 30.0, qy + 14.0); + cg_line(c, 33.0, qy + 11.0); + cg_line(c, 33.0, qy + 19.0); + cg_close(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, CROWN_WHITE * a); + cg_fill_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + draw_text(c, kit, kit[K_QUEEN], 40.0, qy + 18.0, 11.5, WEIGHT_MEDIUM, false); + // W. SPACES: a state dot, the name, the branch dim + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + draw_text(c, kit, kit[K_SPACES], 14.0, label_base(WS_TOP, GROUP_SPACES, n), 9.5, WEIGHT_SEMIBOLD, false); + // W. "+" at the right of the label opens the next closed space slot; hidden when all are open + if (new_space(sp) != SPACE_NONE) { + const pcx : f64 = SIDE_W - 26.0; + const pcy : f64 = label_base(WS_TOP, GROUP_SPACES, n) - 3.5; + if (on_space_plus(mx, my)) { + round_rect(c, pcx - 8.0, pcy - 8.0, 16.0, 16.0, 4.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE * a); + cg_fill_path(c); + } + cg_line_width(c, 1.3); + cg_move(c, pcx - 4.0, pcy); + cg_line(c, pcx + 4.0, pcy); + cg_move(c, pcx, pcy - 4.0); + cg_line(c, pcx, pcy + 4.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); + } + var i : u32 = 0; + while (i < n) { + const s : u32 = space_at_row(sp, i); + const sy : f64 = row_top(WS_TOP, GROUP_SPACES, n, i); + const sover : bool = in_box(mx, my, 12.0, sy, SIDE_W - 24.0, ROW_H); + side_row(c, sy, ROW_H, s == space, sover, a); + // every session is S_STOPPED until the host side starts agent processes + draw_dot(c, dot_for(S_STOPPED), 20.0, sy + 14.0, a); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + const nw : f64 = draw_text_fit(c, kit, msg1(kit[K_SPACE_NAMES], kit[K_AT], @as(u64, s)), 30.0, sy + 18.0, 11.5, WEIGHT_MEDIUM, false, TEXT_RIGHT); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + draw_text_fit(c, kit, msg1(kit[K_BRANCHES], kit[K_AT], @as(u64, s)), 30.0 + nw + 8.0, sy + 18.0, 10.5, 0.0, false, TEXT_RIGHT); + // W. a hovered space row shows an x that closes the space and its chats (not the last space) + if (sover and n > 1) { + draw_close_x(c, SIDE_W - 26.0, sy + 14.0, on_space_close(mx, my, sy), a); + } + i = i + 1; + } + // W. AGENTS: the sessions of the selected space + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + draw_text(c, kit, kit[K_AGENTS], 14.0, label_base(WS_TOP, GROUP_AGENTS, n), 9.5, WEIGHT_SEMIBOLD, false); + const rows : u32 = sessions_in(ss, space); + if (rows == 0) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + draw_text(c, kit, kit[K_EMPTY], 14.0, row_top(WS_TOP, GROUP_AGENTS, n, 0) + 16.0, 10.5, 0.0, false); + } + var r : u32 = 0; + while (r < rows) { + const si : u32 = session_at_row(ss, space, r); + const k : u32 = session_agent(ss, si); + const ay : f64 = row_top(WS_TOP, GROUP_AGENTS, n, r); + const over : bool = in_box(mx, my, 12.0, ay, SIDE_W - 24.0, AGENT_ROW_H); + side_row(c, ay, AGENT_ROW_H, si == cur, over, a); + draw_agent_mark(c, k, 27.5, ay + 19.0, 6.0, a); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + const nw : f64 = draw_text_fit(c, kit, msg1(kit[K_AGENT_NAMES], kit[K_AT], @as(u64, k)), 40.0, ay + 16.0, 11.5, WEIGHT_MEDIUM, false, TEXT_RIGHT - 16.0); + // N. a second chat of the same agent in this space reads "Claude Code 2" + const cn : u32 = chat_number(ss, si); + if (cn > 1 and cn <= 9) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + draw_text(c, kit, msg1(kit[K_DIGITS], kit[K_AT], @as(u64, cn - 1)), 40.0 + nw + 4.0, ay + 16.0, 11.5, WEIGHT_MEDIUM, false); + } + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE * a); + const ww : f64 = draw_text(c, kit, msg1(kit[K_STATE_WORDS], kit[K_AT], @as(u64, S_STOPPED)), 40.0, ay + 30.0, 9.5, 0.0, false); + cg_arc(c, 40.0 + ww + 5.0, ay + 27.0, 1.5, 0.0, TAU, 0); + cg_fill_path(c); + draw_text_fit(c, kit, msg1(kit[K_SPACE_NAMES], kit[K_AT], @as(u64, space)), 40.0 + ww + 10.0, ay + 30.0, 9.5, 0.0, false, TEXT_RIGHT); + // N. the hovered row trades its dot for a close x + if (over) { + draw_close_x(c, SIDE_W - 26.0, ay + 19.0, on_row_close(mx, my, ay), a); + } else { + draw_dot(c, dot_for(S_STOPPED), SIDE_W - 26.0, ay + 19.0, a); + } + r = r + 1; + } + // W. the foot: a hairline, then OPEN SOURCE T27, which opens the repository (windows.t27 REPO_URL) + cg_fill_rgba(c, 0.0, 0.0, 0.0, a); + cg_fill_rect(c, 0.0, sh - FOOT_H, SIDE_W, FOOT_H); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE * a); + cg_fill_rect(c, 12.0, sh - FOOT_H, SIDE_W - 24.0, HAIR); + var la : f64 = LABEL_WHITE; + if (on_repo_link(mx, my, sh, ws, collapsed)) { la = TEXT_WHITE; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, la * a); + const lw : f64 = draw_text(c, kit, kit[K_REPO], 14.0, sh - 13.0, 9.5, WEIGHT_SEMIBOLD, false); + if (on_repo_link(mx, my, sh, ws, collapsed)) { + cg_fill_rect(c, 14.0, sh - 10.5, lw, HAIR); + } + // D. DOCTOR n at the right end of the foot opens the DOCTOR panel (doctor.t27) + var da : f64 = LABEL_WHITE; + if (dlit or on_doctor_link(mx, my, sh, ws, collapsed)) { da = TEXT_WHITE; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, da * a); + draw_text(c, kit, dlabel, SIDE_W - 14.0 - DOC_LINK_W + 22.0, sh - 13.0, 9.5, WEIGHT_SEMIBOLD, false); + } + cg_fill_rgba(c, 0.0, 0.0, 0.0, STRICT_CONTENT * a); + cg_fill_rect(c, cx, WS_TOP, sw - cx, sh - WS_TOP); + if (ws == WS_COMPACT) { + draw_dash_bar(c, kit, a, sw, on_open_dash(mx, my, sw, ws)); + } else { + draw_header(c, kit, shown, a, collapsed, mx, my, sw, ws); + } + draw_cards(c, kit, task, a, collapsed, mx, my, sw, sh, ws); +} + +/// C. The 44 pt header: sidebar.left toggle, Close Dashboard when forced open, the title, the divider. +fn draw_header(c: u64, kit: [48]u64, shown: u32, a: f64, collapsed: bool, mx: f64, my: f64, sw: f64, ws: u32) -> void { + const cx : f64 = content_x(ws, collapsed); + // header: sidebar.left toggle, then the title + const ty : f64 = WS_TOP + 0.5 * (HEADER_H - TOGGLE_PT); + if (on_side_toggle(mx, my, ws, collapsed)) { + round_rect(c, cx + 14.0, ty, TOGGLE_PT, TOGGLE_PT, 6.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE * a); + cg_fill_path(c); + } + const ix : f64 = cx + 14.0 + 6.0; + const iy : f64 = ty + 7.5; + cg_line_width(c, 1.3); + round_rect(c, ix, iy, 16.0, 13.0, 3.0); + cg_move(c, ix + 5.5, iy); + cg_line(c, ix + 5.5, iy + 13.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); + var title_x : f64 = cx + 14.0 + TOGGLE_PT + 12.0; + if (ws == WS_DASHBOARD) { + // K. rectangle.compress.vertical, grokMuted: two arrows closing on a line + const kx : f64 = close_x(ws, collapsed); + if (on_close_dash(mx, my, ws, collapsed)) { + round_rect(c, kx, ty, TOGGLE_PT, TOGGLE_PT, 6.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE * a); + cg_fill_path(c); + } + const mxc : f64 = kx + 0.5 * TOGGLE_PT; + const myc : f64 = ty + 0.5 * TOGGLE_PT; + cg_line_width(c, 1.3); + cg_move(c, mxc - 7.0, myc); + cg_line(c, mxc + 7.0, myc); + cg_move(c, mxc, myc - 8.0); + cg_line(c, mxc, myc - 2.5); + cg_move(c, mxc - 3.0, myc - 5.5); + cg_line(c, mxc, myc - 2.5); + cg_line(c, mxc + 3.0, myc - 5.5); + cg_move(c, mxc, myc + 8.0); + cg_line(c, mxc, myc + 2.5); + cg_move(c, mxc - 3.0, myc + 5.5); + cg_line(c, mxc, myc + 2.5); + cg_line(c, mxc + 3.0, myc + 5.5); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); + title_x = kx + TOGGLE_PT + 12.0; + } + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + draw_text(c, kit, world(kit, shown), title_x, WS_TOP + 0.5 * HEADER_H + 4.2, 12.0, WEIGHT_SEMIBOLD, false); + // N. "+": a new chat for the selected agent, so several Claude Codes can run side by side + const px : f64 = new_chat_x(sw); + if (on_new_chat(mx, my, sw, ws)) { + round_rect(c, px, ty, TOGGLE_PT, TOGGLE_PT, 6.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE * a); + cg_fill_path(c); + } + cg_line_width(c, 1.3); + cg_move(c, px + 0.5 * TOGGLE_PT - 6.0, ty + 0.5 * TOGGLE_PT); + cg_line(c, px + 0.5 * TOGGLE_PT + 6.0, ty + 0.5 * TOGGLE_PT); + cg_move(c, px + 0.5 * TOGGLE_PT, ty + 0.5 * TOGGLE_PT - 6.0); + cg_line(c, px + 0.5 * TOGGLE_PT, ty + 0.5 * TOGGLE_PT + 6.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE * a); + cg_fill_rect(c, cx, WS_TOP + HEADER_H, sw - cx, HAIR); +} + +/// K. trios dashboardToggleButton: rectangle.split.3x1 (white 0.4), "OPEN DASHBOARD" (bold 10, white +/// 0.5), the expand arrows (grokMuted) on the right; elevated 0.25 fill, a 1 pt border 0.3 line under it. +fn draw_dash_bar(c: u64, kit: [48]u64, a: f64, sw: f64, lit: bool) -> void { + var f : f64 = ELEVATED_BLACK * 0.25; + if (lit) { f = ELEVATED_BLACK * 0.5; } + cg_fill_rgba(c, 0.0, 0.0, 0.0, f * a); + cg_fill_rect(c, 0.0, WS_TOP, sw, BAR_H); + cg_fill_rgba(c, 1.0, 1.0, 1.0, BORDER_WHITE * 0.3 * a); + cg_fill_rect(c, 0.0, WS_TOP + BAR_H - 1.0, sw, 1.0); + const mid : f64 = WS_TOP + 0.5 * BAR_H; + cg_line_width(c, 1.1); + round_rect(c, 14.0, mid - 4.5, 13.0, 9.0, 1.5); + cg_move(c, 18.3, mid - 4.5); + cg_line(c, 18.3, mid + 4.5); + cg_move(c, 22.7, mid - 4.5); + cg_line(c, 22.7, mid + 4.5); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, 0.4 * a); + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, 0.5 * a); + draw_text(c, kit, kit[K_OPEN_DASH], 33.0, mid + 3.6, 10.0, WEIGHT_BOLD, false); + const x : f64 = sw - 14.0 - 8.0; + const y : f64 = mid - 4.0; + cg_line_width(c, 1.1); + cg_move(c, x, y); + cg_line(c, x + 8.0, y + 8.0); + cg_move(c, x, y + 3.5); + cg_line(c, x, y); + cg_line(c, x + 3.5, y); + cg_move(c, x + 8.0, y + 4.5); + cg_line(c, x + 8.0, y + 8.0); + cg_line(c, x + 4.5, y + 8.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE * a); + cg_stroke_path(c); +} + +/// A. Unit rays at 45 degree steps for the Claude Code spark. +pub const RAY_X : [8]f64 = [1.0, 0.7071067811865476, 0.0, -0.7071067811865476, -1.0, -0.7071067811865476, 0.0, 0.7071067811865476]; +pub const RAY_Y : [8]f64 = [0.0, 0.7071067811865476, 1.0, 0.7071067811865476, 0.0, -0.7071067811865476, -1.0, -0.7071067811865476]; +/// A. The mark is AGENT_MARK of the cell radius, its centre AGENT_MARK_UP above the cell centre; the +/// name sits AGENT_NAME_DOWN below it. White only (FR-006), at MARK_WHITE. +pub const AGENT_MARK : f64 = 0.26; +pub const AGENT_MARK_UP : f64 = 0.10; +pub const AGENT_NAME_DOWN : f64 = 0.42; +pub const AGENT_NAME_PT : f64 = 12.0; +pub const MARK_WHITE : f64 = 0.85; +/// A. The name shrinks with the cell (AGENT_NAME_SHARE of the radius, at most AGENT_NAME_PT) and is left +/// out below AGENT_NAME_MIN, where it would not be readable. +pub const AGENT_NAME_SHARE : f64 = 0.15; +pub const AGENT_NAME_MIN : f64 = 8.0; + +pub fn agent_name_pt(r: f64) -> f64 { + if (AGENT_NAME_SHARE * r < AGENT_NAME_PT) { return AGENT_NAME_SHARE * r; } + return AGENT_NAME_PT; +} + +/// A. The mark of agent k centred at (x, y), half-size s: vector glyphs drawn here, not downloaded images. +fn draw_agent_mark(c: u64, k: u32, x: f64, y: f64, s: f64, a: f64) -> void { + cg_line_width(c, 0.12 * s); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MARK_WHITE * a); + cg_fill_rgba(c, 1.0, 1.0, 1.0, MARK_WHITE * a); + if (k == 0) { + // Claude Code: a spark, long and short rays in turn + cg_line_width(c, 0.18 * s); + var i : u32 = 0; + while (i < 8) { + var r : f64 = s; + if (i % 2 == 1) { r = 0.7 * s; } + cg_move(c, x + 0.18 * s * RAY_X[i], y + 0.18 * s * RAY_Y[i]); + cg_line(c, x + r * RAY_X[i], y + r * RAY_Y[i]); + i = i + 1; + } + cg_stroke_path(c); + } else if (k == 1) { + // Codex: a prompt in a rounded square + round_rect(c, x - s, y - s, 2.0 * s, 2.0 * s, 0.4 * s); + cg_move(c, x - 0.5 * s, y - 0.4 * s); + cg_line(c, x - 0.1 * s, y); + cg_line(c, x - 0.5 * s, y + 0.4 * s); + cg_move(c, x + 0.05 * s, y + 0.45 * s); + cg_line(c, x + 0.55 * s, y + 0.45 * s); + cg_stroke_path(c); + } else if (k == 2) { + // Gemini: a four-point star + const w : f64 = 0.2 * s; + cg_move(c, x, y - s); + cg_line(c, x + w, y - w); + cg_line(c, x + s, y); + cg_line(c, x + w, y + w); + cg_line(c, x, y + s); + cg_line(c, x - w, y + w); + cg_line(c, x - s, y); + cg_line(c, x - w, y - w); + cg_close(c); + cg_fill_path(c); + } else if (k == 3) { + // OpenCode: an open frame around a solid block + cg_move(c, x + 0.3 * s, y - s); + cg_line(c, x - s, y - s); + cg_line(c, x - s, y + s); + cg_line(c, x + s, y + s); + cg_line(c, x + s, y - 0.3 * s); + cg_stroke_path(c); + cg_fill_rect(c, x - 0.35 * s, y - 0.35 * s, 0.7 * s, 0.7 * s); + } else if (k == 4) { + // Queen: the crown of the sidebar row + cg_move(c, x - s, y + 0.7 * s); + cg_line(c, x - s, y - 0.5 * s); + cg_line(c, x - 0.45 * s, y); + cg_line(c, x, y - 0.9 * s); + cg_line(c, x + 0.45 * s, y); + cg_line(c, x + s, y - 0.5 * s); + cg_line(c, x + s, y + 0.7 * s); + cg_close(c); + cg_fill_path(c); + } else if (k == 5) { + // Crush: a heart, one path (two lobes over the top, a point below) so no fill stacks + cg_move(c, x, y + s); + cg_arc(c, x - 0.45 * s, y - 0.3 * s, 0.5 * s, 0.5 * TAU, TAU, 0); + cg_arc(c, x + 0.45 * s, y - 0.3 * s, 0.5 * s, 0.5 * TAU, TAU, 0); + cg_close(c); + cg_fill_path(c); + } else if (k == 6) { + // Cursor: a cube seen corner-on + const hw : f64 = 0.5 * SQRT3 * s; + hex_path(c, x, y, s); + cg_move(c, x, y); + cg_line(c, x, y + s); + cg_move(c, x, y); + cg_line(c, x - hw, y - 0.5 * s); + cg_move(c, x, y); + cg_line(c, x + hw, y - 0.5 * s); + cg_stroke_path(c); + } else if (k == 7) { + // Aider: a speech bubble with three lines + round_rect(c, x - s, y - 0.8 * s, 2.0 * s, 1.4 * s, 0.3 * s); + cg_move(c, x - 0.5 * s, y + 0.6 * s); + cg_line(c, x - 0.6 * s, y + s); + cg_line(c, x - 0.1 * s, y + 0.6 * s); + cg_move(c, x - 0.6 * s, y - 0.4 * s); + cg_line(c, x + 0.6 * s, y - 0.4 * s); + cg_move(c, x - 0.6 * s, y - 0.1 * s); + cg_line(c, x + 0.6 * s, y - 0.1 * s); + cg_move(c, x - 0.6 * s, y + 0.2 * s); + cg_line(c, x + 0.2 * s, y + 0.2 * s); + cg_stroke_path(c); + } else { + // Copilot: goggles + round_rect(c, x - s, y - 0.45 * s, 0.85 * s, 0.9 * s, 0.3 * s); + round_rect(c, x + 0.15 * s, y - 0.45 * s, 0.85 * s, 0.9 * s, 0.3 * s); + cg_move(c, x - 0.15 * s, y - 0.1 * s); + cg_line(c, x + 0.15 * s, y - 0.1 * s); + cg_stroke_path(c); + cg_fill_rect(c, x - 0.6 * s, y - 0.1 * s, 0.12 * s, 0.3 * s); + cg_fill_rect(c, x + 0.48 * s, y - 0.1 * s, 0.12 * s, 0.3 * s); + } +} + +/// C. The nine card slots as a honeycomb in the readable column; A. each cell carries its agent mark and name. +/// F. The outline of comb cell k, given the hovered cell hk and the open task: the same width for every +/// cell, the alpha set only by hover and selection. The width is set before each cell's stroke because +/// draw_agent_mark changes the line width -- cell 0 alone got the hairline while every later cell was stroked +/// at the previous mark's width (owner, 2026-10-19: "why is this cell bolder than the others? it's a bug"). +pub fn cell_line(k: u32) -> f64 { + return HAIR; +} + +pub fn cell_edge(k: u32, hk: u32, task: u32) -> f64 { + if (k == task) { return TASK_EDGE; } + if (k == hk) { return STRICT_HOVER_EDGE; } + return STRICT_CARD_EDGE; +} + +fn draw_cards(c: u64, kit: [48]u64, task: u32, a: f64, collapsed: bool, mx: f64, my: f64, sw: f64, sh: f64, ws: u32) -> void { + const l : f64 = body_left(sw, ws, collapsed); + const t : f64 = body_top(ws); + const w : f64 = body_w(sw, ws, collapsed); + const h : f64 = body_h(sw, sh, ws, collapsed); + const r : f64 = hex_r(w, h); + var hk : u32 = CARD_SLOTS; + if (a >= 1.0) { hk = card_at(mx, my, sw, sh, ws, collapsed); } + // F. strict cells: hairline hexagons, a faint fill, no hover lift + var k : u32 = 0; + while (k < CARD_SLOTS) { + const ka : f64 = card_alpha(a, k); + var kf : f64 = STRICT_CARD_FILL; + const lift : f64 = (1.0 - ka) * 6.0; + if (k == hk) { + kf = STRICT_HOVER_FILL; + } + if (k == task) { + kf = TASK_FILL; + } + const kx : f64 = hex_c(k, 0, l, t, w, h); + const ky : f64 = hex_c(k, 1, l, t, w, h) + lift; + hex_path(c, kx, ky, r - 0.25); + cg_fill_rgba(c, 1.0, 1.0, 1.0, kf * ka); + cg_fill_path(c); + cg_line_width(c, cell_line(k)); + hex_path(c, kx, ky, r - 0.25); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, cell_edge(k, hk, task) * ka); + cg_stroke_path(c); + draw_agent_mark(c, k, kx, ky - AGENT_MARK_UP * r, AGENT_MARK * r, ka); + const ns : f64 = agent_name_pt(r); + if (ns >= AGENT_NAME_MIN) { + const name : u64 = msg1(kit[K_AGENT_NAMES], kit[K_AT], @as(u64, k)); + const nw : f64 = text_w(kit, name, ns, WEIGHT_SEMIBOLD, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * ka); + draw_text(c, kit, name, kx - 0.5 * nw, ky + AGENT_NAME_DOWN * r, ns, WEIGHT_SEMIBOLD, false); + } + k = k + 1; + } +} + +/// F. The background: one opaque black fill over the whole frame (#000000), in a window and in full screen. +fn draw_backdrop(c: u64, sw: f64, sh: f64, full: bool) -> void { + cg_fill_rgba(c, 0.0, 0.0, 0.0, BACK_BLACK); + cg_fill_rect(c, 0.0, 0.0, sw, sh); +} + +/// One frame over the tinted glass: the home logo at 1 - ma, the screen for `shown` at ma, the title bar. +/// Q. The Queen panel (queen.t27): a black column on the right over any view -- GOD MODE, what the +/// user is looking at, her suggestion with Confirm and Dismiss, and the RECENT log of user and Queen +/// actions, newest first. A filled dot marks her actions, a ring the user's. +fn draw_queen_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, view: u32, space: u32, ss: u64, cur: u32, ring: [8]u64, n: u32, now: u64, sug: u32, god: bool) -> void { + const l : f64 = qp_left(sw); + const t : f64 = WS_TOP; + const x : f64 = l + QP_PAD; + cg_fill_rgba(c, 0.0, 0.0, 0.0, 0.94); + cg_fill_rect(c, l, t, QP_W, sh - t); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, l, t, HAIR, sh - t); + // the crown and QUEEN + cg_move(c, x, t + 26.0); + cg_line(c, x, t + 18.0); + cg_line(c, x + 3.0, t + 21.0); + cg_line(c, x + 5.5, t + 16.0); + cg_line(c, x + 8.0, t + 21.0); + cg_line(c, x + 11.0, t + 18.0); + cg_line(c, x + 11.0, t + 26.0); + cg_close(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, CROWN_WHITE); + cg_fill_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_QUEEN)), x + 18.0, t + 25.0, 11.5, WEIGHT_SEMIBOLD, false); + draw_close_x(c, sw - QP_PAD - 8.0, t + 20.0, on_panel_close(mx, my, sw, t), 1.0); + // GOD MODE: an empty box when off, a filled one when on, and the words + const gy : f64 = t + QP_GOD_Y; + round_rect(c, x, gy, QP_W - 2.0 * QP_PAD, QP_GOD_H, 6.0); + if (on_god(mx, my, sw, t)) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE); + cg_fill_path(c); + round_rect(c, x, gy, QP_W - 2.0 * QP_PAD, QP_GOD_H, 6.0); + } + cg_line_width(c, 1.0); + if (god) { cg_line_width(c, 2.0); } + cg_stroke_rgba(c, 1.0, 1.0, 1.0, STRICT_HOVER_EDGE); + if (god) { cg_stroke_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); } + cg_stroke_path(c); + round_rect(c, x + 12.0, gy + 15.0, 14.0, 14.0, 3.0); + if (god) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + cg_fill_path(c); + } else { + cg_line_width(c, 1.3); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + cg_stroke_path(c); + } + var gw : u32 = QL_GOD_OFF; + var gs : u32 = QL_SUB_OFF; + if (god) { gw = QL_GOD_ON; gs = QL_SUB_ON; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, gw)), x + 36.0, gy + 19.0, 11.5, WEIGHT_BOLD, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, gs)), x + 36.0, gy + 34.0, 10.0, 0.0, false); + // LOOKING AT: the view, then the space and the selected chat + const cy : f64 = t + QP_CTX_Y; + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_LOOKING)), x, cy, 9.5, WEIGHT_SEMIBOLD, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + if (view == VIEW_HOME) { + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_HOME)), x, cy + 20.0, 11.5, WEIGHT_MEDIUM, false); + } else { + draw_text(c, kit, world(kit, view), x, cy + 20.0, 11.5, WEIGHT_MEDIUM, false); + } + const sw1 : f64 = draw_text(c, kit, msg1(kit[K_SPACE_NAMES], kit[K_AT], @as(u64, space)), x, cy + 38.0, 10.5, WEIGHT_MEDIUM, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + if (cur == SESSION_NONE) { + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_NO_CHAT)), x + sw1 + 8.0, cy + 38.0, 10.5, 0.0, false); + } else { + const aw : f64 = draw_text(c, kit, msg1(kit[K_AGENT_NAMES], kit[K_AT], @as(u64, session_agent(ss, cur))), x + sw1 + 8.0, cy + 38.0, 10.5, 0.0, false); + const cn : u32 = chat_number(ss, cur); + if (cn > 1 and cn <= 9) { + draw_text(c, kit, msg1(kit[K_DIGITS], kit[K_AT], @as(u64, cn - 1)), x + sw1 + 8.0 + aw + 4.0, cy + 38.0, 10.5, 0.0, false); + } + } + // SUGGESTION, and the two buttons that put a human on every action + const sy : f64 = t + QP_SUG_Y; + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_SUGGESTION)), x, sy, 9.5, WEIGHT_SEMIBOLD, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, msg1(kit[K_SUGGEST], kit[K_AT], @as(u64, sug)), x, sy + 20.0, 11.5, WEIGHT_MEDIUM, false); + if (sug != A_NONE) { + const by : f64 = t + QP_BTN_Y; + // Confirm: a white box, black words + round_rect(c, x, by, QP_BTN_W, QP_BTN_H, 6.0); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + if (on_confirm(mx, my, sw, t)) { cg_fill_rgba(c, 1.0, 1.0, 1.0, 1.0); } + cg_fill_path(c); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_CONFIRM)), x + 14.0, by + 17.0, 11.0, WEIGHT_SEMIBOLD, false); + // Dismiss: an outlined box + const dx : f64 = x + QP_BTN_W + QP_BTN_GAP; + round_rect(c, dx, by, QP_BTN_W, QP_BTN_H, 6.0); + if (on_dismiss(mx, my, sw, t)) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, HIGHLIGHT_WHITE); + cg_fill_path(c); + round_rect(c, dx, by, QP_BTN_W, QP_BTN_H, 6.0); + } + cg_line_width(c, 1.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, STRICT_HOVER_EDGE); + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_DISMISS)), dx + 14.0, by + 17.0, 11.0, WEIGHT_SEMIBOLD, false); + } + // RECENT: newest first; her actions a filled dot, the user's a ring + const ry : f64 = t + QP_RECENT_Y; + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_RECENT)), x, ry, 9.5, WEIGHT_SEMIBOLD, false); + if (n == 0) { + draw_text(c, kit, msg1(kit[K_QP], kit[K_AT], @as(u64, QL_EMPTY)), x, ry + 20.0, 10.5, 0.0, false); + } + const rows : u32 = recent_rows(sh, t); + var b : u32 = 0; + while (b < rows) { + const slot : u32 = ring_at(n, b); + if (slot != RING_NONE) { + const e : u64 = ring[slot]; + const k : u32 = ev_control(e); + const ly : f64 = ry + 20.0 + @as(f64, @floatFromInt(b)) * QP_LINE; + if (by_queen(k)) { + draw_dot(c, DOT_FILLED, x + 3.0, ly - 4.0, 1.0); + } else { + draw_dot(c, DOT_RING, x + 3.0, ly - 4.0, 1.0); + } + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + const nw : f64 = draw_text(c, kit, msg1(kit[K_CONTROLS], kit[K_AT], @as(u64, k)), x + 14.0, ly, 11.0, 0.0, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + const ak : u32 = arg_kind(k); + if (ak == ARG_AGENT) { + draw_text(c, kit, msg1(kit[K_AGENT_NAMES], kit[K_AT], @as(u64, ev_arg(e))), x + 14.0 + nw + 6.0, ly, 10.5, 0.0, false); + } else if (ak == ARG_SPACE) { + draw_text(c, kit, msg1(kit[K_SPACE_NAMES], kit[K_AT], @as(u64, ev_arg(e))), x + 14.0 + nw + 6.0, ly, 10.5, 0.0, false); + } + draw_text(c, kit, msg1(kit[K_AGES], kit[K_AT], @as(u64, age_word(now, ev_sec(e)))), sw - QP_PAD - 72.0, ly, 10.0, 0.0, false); + } + b = b + 1; + } +} + +/// Q. The GOD MODE glow (queen.t27): a blue gas flame along all four edges at level `g` on frame +/// `frame` -- GLOW_BANDS thin strips coloured cone to base (flame_rgb) with the spec's falloff and +/// flicker, then the cone hairline at the very edge with a GLOW_BLUR pt blue shadow. Nothing is drawn +/// at level 0, so the human-confirm mode keeps black edges. +fn draw_edge_glow(c: u64, sw: f64, sh: f64, g: f64, frame: u32) -> void { + if (g <= 0.0) { return; } + const bw : f64 = GLOW_W / @as(f64, @floatFromInt(GLOW_BANDS)); + var b : u32 = 0; + while (b < GLOW_BANDS) { + const d : f64 = @as(f64, @floatFromInt(b)) * bw; + cg_fill_rgba(c, flame_rgb(b, 0), flame_rgb(b, 1), flame_rgb(b, 2), glow_alpha(g, b, frame)); + cg_fill_rect(c, d, d, sw - 2.0 * d, bw); + cg_fill_rect(c, d, sh - d - bw, sw - 2.0 * d, bw); + cg_fill_rect(c, d, d + bw, bw, sh - 2.0 * d - 2.0 * bw); + cg_fill_rect(c, sw - d - bw, d + bw, bw, sh - 2.0 * d - 2.0 * bw); + b = b + 1; + } + const ca : f64 = core_alpha(g, frame); + const halo : u64 = cg_color(FLAME_MID_R, FLAME_MID_G, FLAME_MID_B, ca); + cg_save(c); + cg_shadow(c, 0.0, 0.0, GLOW_BLUR, halo); + cg_fill_rgba(c, FLAME_CORE_R, FLAME_CORE_G, FLAME_CORE_B, ca); + const cw : f64 = GLOW_CORE_W; + cg_fill_rect(c, 0.0, 0.0, sw, cw); + cg_fill_rect(c, 0.0, sh - cw, sw, cw); + cg_fill_rect(c, 0.0, cw, cw, sh - 2.0 * cw); + cg_fill_rect(c, sw - cw, cw, cw, sh - 2.0 * cw); + cg_restore(c); + cg_color_release(halo); +} + +// ---- the terminal pane (Claude Code, AGENT 0) ---- +// The pane talks to the T27 OS PTY server through specs/t27os/pty_client.t27: blocking round trips on +// ~/.t27os/pty.sock, one frame out and one back. Which server pane a chat is, and how a reply reads, are +// term_pane.t27 decisions; when no server answers the pane runs offline, echoing its own keys. + +/// The UTF-8 bytes of an NSString into out; the count, cut at out.len. +fn ns_bytes(s: u64, out: []u8) -> usize { + const p : u64 = msg0(s, sel("UTF8String")); + if (p == 0) { return 0; } + var n : u64 = sys_strlen(p); + if (n > @as(u64, out.len)) { n = @as(u64, out.len); } + sys_peek(out, p, n); + return @as(usize, n); +} + +/// Connect to the server under the home directory; the fd, -1 when nothing listens. +fn term_connect(hb: []u8) -> i32 { + const hn : usize = ns_bytes(home_dir(), hb); + return client_connect(hb[0 .. hn]); +} + +/// The server pane for chat `cur`: the k-th pane of its agent when the server has one (a reopened +/// window lands here), else a new pane spawned in `cwd`. PANE_NONE when the server refuses or is gone. +fn term_open(fd: i32, ss: u64, cur: u32, cwd: u64, out: []u8, db: []u8) -> u32 { + const a : u32 = session_agent(ss, cur); + const ln : usize = client_list(fd, out); + if (ln == 0) { return PANE_NONE; } + const have : u32 = tp_pick_pane(out, ln, a, tp_ordinal(ss, cur)); + if (have != PANE_NONE) { return have; } + const dn : usize = ns_bytes(cwd, db); + const sn : usize = client_spawn(fd, a, session_space(ss, cur), cur, db[0 .. dn], out); + return tp_spawned(out, sn); +} + +/// The offline banner: title, the cwd the pane opened in, the offline note and a prompt. +fn term_banner(st: []u32, cells: []u32, cwd: u64) -> bool { + var nl : [2]u8 = undefined; + nl[0] = 13; + nl[1] = 10; + vt_feed_all(st, cells, TP_LINE_TITLE); + vt_feed_all(st, cells, nl[0..2]); + vt_feed_all(st, cells, TP_LINE_CWD); + var pb : [512]u8 = undefined; + pb[0] = 0; + if (cwd != 0) { msg_cstr(cwd, sel("getCString:maxLength:encoding:"), pb[0..512], @as(u64, TP_PATH_MAX), NS_UTF8); } + var i : u32 = 0; + var going : bool = true; + while (going and i < TP_PATH_MAX) { + if (pb[i] == 0) { going = false; } else { vt_feed(st, cells, pb[i]); i = i + 1; } + } + vt_feed_all(st, cells, nl[0..2]); + vt_feed_all(st, cells, TP_LINE_OFFLINE); + vt_feed_all(st, cells, nl[0..2]); + vt_feed_all(st, cells, nl[0..2]); + vt_feed_all(st, cells, TP_PROMPT); + return true; +} + +/// The pane: black content area under the header, one monospace line per grid row, a white +/// block cursor with its character redrawn in black. +fn draw_term(c: u64, kit: [48]u64, st: []const u32, cells: []const u32, curon: bool, a: f64, collapsed: bool, sw: f64, sh: f64, ws: u32) -> void { + const ox : f64 = content_x(ws, collapsed); + const oy : f64 = WS_TOP + HEADER_H; + cg_fill_rgba(c, 0.0, 0.0, 0.0, a); + cg_fill_rect(c, ox, oy + HAIR, sw - ox, sh - oy - HAIR); + const cols : u32 = st[ST_COLS]; + const rows : u32 = st[ST_ROWS]; + var lb : [1600]u8 = undefined; + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + var y : u32 = 0; + while (y < rows) { + var keep : u32 = 0; + var x : u32 = 0; + while (x < cols) { + if (cells[y * cols + x] != BLANK) { keep = x + 1; } + x = x + 1; + } + if (keep > 0) { + var n : u32 = 0; + x = 0; + while (x < keep) { + n = tp_utf8(lb[0..1600], n, cells[y * cols + x]); + x = x + 1; + } + const s : u64 = cf_bytes(0, lb[0..1600], @as(usize, @intCast(n)), UTF8, false); + if (s != 0) { + draw_text(c, kit, s, tp_cell_x(ox, 0), tp_cell_y(oy, y) + TP_BASE, TP_FONT, 0.0, true); + cf_release(s); + } + } + y = y + 1; + } + if (curon and rows > 0) { + const cx : u32 = tp_cursor_col(st[ST_X], cols); + var cy : u32 = st[ST_Y]; + if (cy >= rows) { cy = rows - 1; } + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE * a); + cg_fill_rect(c, tp_cell_x(ox, cx), tp_cell_y(oy, cy), TP_CELL_W, TP_LINE_H); + const under : u32 = cells[cy * cols + cx]; + if (under != BLANK) { + const m : u32 = tp_utf8(lb[0..1600], 0, under); + const g : u64 = cf_bytes(0, lb[0..1600], @as(usize, @intCast(m)), UTF8, false); + if (g != 0) { + cg_fill_rgba(c, 0.0, 0.0, 0.0, a); + draw_text(c, kit, g, tp_cell_x(ox, cx), tp_cell_y(oy, cy) + TP_BASE, TP_FONT, 0.0, true); + cf_release(g); + } + } + } +} + +fn draw(c: u64, kit: [48]u64, frame: u32, state: u32, hov: u32, ma: f64, shown: u32, task: u32, space: u32, sp: u32, ss: u64, cur: u32, collapsed: bool, mx: f64, my: f64, z: f64, sw: f64, sh: f64, ws: u32, full: bool, qp: bool, ring: [8]u64, n: u32, now: u64, sug: u32, god: bool, glow: f64, dk: [128]u64, dp: bool, darmed: u32, doff: u32, dgo: u32, dlive: bool, dlabel: u64, tst: []const u32, tcells: []const u32, topen: u32, tcur: bool) -> void { + cg_clear(c, 0.0, 0.0, sw, sh); + draw_backdrop(c, sw, sh, full); + const ha : f64 = 1.0 - ma; + if (ha > 0.0) { + draw_logo(c, home_c(0, z, sw, sh), home_c(1, z, sw, sh), home_k(z, sw, sh), anim_off(frame) * 1000.0, state, hov, ha); + if (hov != PETAL_NONE) { draw_tooltip(c, kit, hov, ha, z, sw, sh); } + } + if (ma > 0.0) { + draw_chat(c, kit, shown, task, space, sp, ss, cur, ma, collapsed, mx, my, sw, sh, ws, dlabel, dp); + if (topen != PANE_NONE) { draw_term(c, kit, tst, tcells, tcur, ma, collapsed, sw, sh, ws); } + } + if (qp) { draw_queen_panel(c, kit, sw, sh, mx, my, view_of(menu_open(state), shown), space, ss, cur, ring, n, now, sug, god); } + if (dp and qp == false) { draw_doctor_panel(c, kit, dk, sw, sh, mx, my, darmed, doff, dgo, dlive); } + draw_titlebar(c, kit, sw, full, ma); + draw_edge_glow(c, sw, sh, glow, frame); +} + +// ---- the DOCTOR host (doctor.t27): the handles it needs, made once, then the walk, the checks, the sums ---- +// doctor.t27 decides; this block only lists folders, runs the command lines it names, reads their output and +// moves a confirmed folder to the Trash. + +pub const NS_NOT_FOUND : u64 = 0x7fffffffffffffff; +pub const DK_SIZE : u32 = 128; +// columns, one entry per known repository, in discovery order +pub const DK_PATHS : u32 = 0; +pub const DK_FLAGS : u32 = 1; +pub const DK_KB : u32 = 2; +pub const DK_BUILD : u32 = 3; +pub const DK_REMOTE : u32 = 4; +pub const DK_BRANCH : u32 = 5; +pub const DK_MARK : u32 = 6; +// the walk queue +pub const DK_QUEUE : u32 = 7; +pub const DK_QDEPTH : u32 = 8; +pub const DK_QROOT : u32 = 9; +// sets and lists read from doctor.t27 +pub const DK_SKIP : u32 = 10; +pub const DK_ALLOWED : u32 = 11; +pub const DK_AGENTS : u32 = 12; +pub const DK_JOBS : u32 = 13; +pub const DK_SLOTS : u32 = 14; +pub const DK_HIDDEN : u32 = 15; +pub const DK_ROWS : u32 = 16; +pub const DK_TASKS : u32 = 17; +pub const DK_TPATH : u32 = 18; +pub const DK_TJOB : u32 = 19; +pub const DK_OUTS : u32 = 20; +pub const DK_HOME : u32 = 21; +pub const DK_WORDS : u32 = 22; +pub const DK_FWORDS : u32 = 23; +pub const DK_KWORDS : u32 = 24; +pub const DK_NULL : u32 = 25; +pub const DK_EMPTY : u32 = 26; +pub const DK_FM : u32 = 27; +pub const DK_SEEDN : u32 = 28; +pub const DK_SEEDB : u32 = 29; +// strings +pub const DK_GIT : u32 = 30; +pub const DK_HEADF : u32 = 31; +pub const DK_WTS : u32 = 32; +pub const DK_DOT : u32 = 33; +pub const DK_SLASH : u32 = 34; +pub const DK_NL : u32 = 35; +pub const DK_MARKP : u32 = 36; +pub const DK_ARGSEP : u32 = 37; +pub const DK_STH : u32 = 38; +pub const DK_STHEAD : u32 = 39; +pub const DK_STSTASH : u32 = 40; +pub const DK_STEMRX : u32 = 41; +pub const DK_COMMA : u32 = 42; +pub const DK_SPACE : u32 = 43; +pub const DK_POINT : u32 = 44; +pub const DK_DEVNULL : u32 = 45; +pub const DK_WHITE : u32 = 46; +// classes +pub const DK_NUMBER : u32 = 47; +pub const DK_URL_CLS : u32 = 48; +pub const DK_STR_CLS : u32 = 49; +pub const DK_FH_CLS : u32 = 50; +pub const DK_TASK_CLS : u32 = 51; +pub const DK_DICT_CLS : u32 = 52; +pub const DK_CSET_CLS : u32 = 53; +// selectors +pub const DK_COUNT : u32 = 60; +pub const DK_AT : u32 = 61; +pub const DK_ADD : u32 = 62; +pub const DK_REMOVE_AT : u32 = 63; +pub const DK_REPLACE : u32 = 64; +pub const DK_INDEX : u32 = 65; +pub const DK_CONTAINS : u32 = 66; +pub const DK_NUM : u32 = 67; +pub const DK_VAL : u32 = 68; +pub const DK_APPEND_PATH : u32 = 69; +pub const DK_APPEND : u32 = 70; +pub const DK_EXISTS : u32 = 71; +pub const DK_PREFIX : u32 = 72; +pub const DK_RANGE : u32 = 73; +pub const DK_LAST : u32 = 74; +pub const DK_LIST : u32 = 75; +pub const DK_URL_PATH : u32 = 76; +pub const DK_IS_DIR : u32 = 77; +pub const DK_CLEAR : u32 = 78; +pub const DK_RUNNING : u32 = 79; +pub const DK_STATUS : u32 = 80; +pub const DK_LAUNCH : u32 = 81; +pub const DK_SET_LAUNCH : u32 = 82; +pub const DK_SET_ARGS : u32 = 83; +pub const DK_SET_OUT : u32 = 84; +pub const DK_SET_ERR : u32 = 85; +pub const DK_SET_IN : u32 = 86; +pub const DK_REPL : u32 = 87; +pub const DK_SPLIT : u32 = 88; +pub const DK_READ : u32 = 89; +pub const DK_CREATE : u32 = 90; +pub const DK_FH_WRITE : u32 = 91; +pub const DK_CLOSE : u32 = 92; +pub const DK_LONG : u32 = 93; +pub const DK_FILE_URL : u32 = 94; +pub const DK_TRASH : u32 = 95; +pub const DK_LENGTH : u32 = 96; +pub const DK_STRVAL : u32 = 97; +pub const DK_TRIM : u32 = 98; +pub const DK_PARENT : u32 = 99; +pub const DK_RX_REPL : u32 = 100; +pub const DK_CSET_COUNT : u32 = 101; +pub const DK_OBJ_FOR : u32 = 102; +pub const DK_SET_OBJ : u32 = 103; +pub const DK_WAIT : u32 = 104; +pub const DK_TILDE : u32 = 105; +pub const DK_NEW : u32 = 106; +pub const DK_RELEASE : u32 = 107; +pub const DK_SAME : u32 = 108; +pub const DK_FROM : u32 = 109; +pub const DK_SUB : u32 = 110; +pub const DK_TMPROOT : u32 = 111; +/// NSRegularExpressionSearch, and the slot the confirm-time re-check runs in (after the JOB_SLOTS). +pub const RX_SEARCH : u64 = 1024; +pub const NS_UTF8 : u64 = 4; +pub const SYNC_SLOT : u64 = 4; +pub const DOC_SUM_EVERY : u32 = 30; + +fn dk_num(dk: [128]u64, x: u64) -> u64 { + return msg1(dk[DK_NUMBER], dk[DK_NUM], x); +} + +fn dk_at(dk: [128]u64, arr: u64, i: u64) -> u64 { + return msg1(arr, dk[DK_AT], i); +} + +fn dk_count(dk: [128]u64, arr: u64) -> u64 { + return msg0(arr, dk[DK_COUNT]); +} + +/// Column k, row i, as a number. +fn dk_get(dk: [128]u64, k: u32, i: u64) -> u64 { + return msg0(dk_at(dk, dk[k], i), dk[DK_VAL]); +} + +fn dk_set(dk: [128]u64, k: u32, i: u64, x: u64) -> void { + msg2(dk[k], dk[DK_REPLACE], i, dk_num(dk, x)); +} + +fn dk_flags(dk: [128]u64, i: u64) -> u32 { + return @as(u32, @intCast(dk_get(dk, DK_FLAGS, i))); +} + +fn dk_put_flags(dk: [128]u64, i: u64, f: u32) -> void { + dk_set(dk, DK_FLAGS, i, @as(u64, f)); +} + +fn dk_exists(dk: [128]u64, p: u64) -> bool { + return msg_test1(dk[DK_FM], dk[DK_EXISTS], p); +} + +fn dk_len(dk: [128]u64, s: u64) -> u64 { + return msg0(s, dk[DK_LENGTH]); +} + +/// A new owned NSMutableArray holding `n` copies of `fill`. +fn dk_array(dk: [128]u64, cls_m: u64, n: u64, fill: u64) -> u64 { + const a : u64 = msg0(cls_m, dk[DK_NEW]); + var i : u64 = 0; + while (i < n) { + msg1(a, dk[DK_ADD], fill); + i = i + 1; + } + return a; +} + +/// Repository p was seen on pass `pass`: mark it, or add it with what its folder says about it. +fn dk_register(dk: [128]u64, p: u64, pass: u64) -> void { + const i : u64 = msg1(dk[DK_PATHS], dk[DK_INDEX], p); + if (i != NS_NOT_FOUND) { + dk_set(dk, DK_MARK, i, pass); + return; + } + const n : u64 = dk_count(dk, dk[DK_PATHS]); + if (n >= @as(u64, LIST_MAX)) { return; } + const git : u64 = msg1(p, dk[DK_APPEND_PATH], dk[DK_GIT]); + var f : u32 = 0; + f = with_flag(f, F_WORKTREE, dk_exists(dk, msg1(git, dk[DK_APPEND_PATH], dk[DK_HEADF])) == false); + f = with_flag(f, F_HAS_WORKTREES, dk_exists(dk, msg1(git, dk[DK_APPEND_PATH], dk[DK_WTS]))); + const slashed : u64 = msg1(p, dk[DK_APPEND], dk[DK_SLASH]); + const na : u64 = dk_count(dk, dk[DK_AGENTS]); + var a : u64 = 0; + while (a < na) { + if (msg1(slashed, dk[DK_RANGE], dk_at(dk, dk[DK_AGENTS], a)) != NS_NOT_FOUND) { f = with_flag(f, F_AGENT, true); } + a = a + 1; + } + // a repository below another: the outer one holds it + var j : u64 = 0; + while (j < n) { + const q : u64 = dk_at(dk, dk[DK_PATHS], j); + const qs : u64 = msg1(q, dk[DK_APPEND], dk[DK_SLASH]); + if (msg_test1(slashed, dk[DK_PREFIX], qs)) { dk_put_flags(dk, j, with_flag(dk_flags(dk, j), F_NESTS, true)); } + if (msg_test1(qs, dk[DK_PREFIX], slashed)) { f = with_flag(f, F_NESTS, true); } + j = j + 1; + } + msg1(dk[DK_PATHS], dk[DK_ADD], p); + msg1(dk[DK_FLAGS], dk[DK_ADD], dk_num(dk, @as(u64, f))); + msg1(dk[DK_KB], dk[DK_ADD], dk_num(dk, 0)); + msg1(dk[DK_BUILD], dk[DK_ADD], dk_num(dk, 0)); + msg1(dk[DK_REMOTE], dk[DK_ADD], dk[DK_EMPTY]); + msg1(dk[DK_BRANCH], dk[DK_ADD], dk[DK_EMPTY]); + msg1(dk[DK_MARK], dk[DK_ADD], dk_num(dk, pass)); +} + +/// Forget repository i; a space bound to it lets go. +fn dk_drop(dk: [128]u64, i: u64) -> void { + const p : u64 = dk_at(dk, dk[DK_PATHS], i); + const s : u64 = msg1(dk[DK_SLOTS], dk[DK_INDEX], p); + if (s != NS_NOT_FOUND) { msg2(dk[DK_SLOTS], dk[DK_REPLACE], s, dk[DK_EMPTY]); } + msg1(dk[DK_FLAGS], dk[DK_REMOVE_AT], i); + msg1(dk[DK_KB], dk[DK_REMOVE_AT], i); + msg1(dk[DK_BUILD], dk[DK_REMOVE_AT], i); + msg1(dk[DK_REMOTE], dk[DK_REMOVE_AT], i); + msg1(dk[DK_BRANCH], dk[DK_REMOVE_AT], i); + msg1(dk[DK_MARK], dk[DK_REMOVE_AT], i); + msg1(dk[DK_PATHS], dk[DK_REMOVE_AT], i); +} + +/// Queue the roots of a new pass. +fn dk_start_pass(dk: [128]u64) -> void { + msg1(dk[DK_QUEUE], dk[DK_ADD], dk[DK_HOME]); + msg1(dk[DK_QDEPTH], dk[DK_ADD], dk_num(dk, 0)); + msg1(dk[DK_QROOT], dk[DK_ADD], dk_num(dk, @as(u64, ROOT_HOME))); + msg1(dk[DK_QUEUE], dk[DK_ADD], dk[DK_TMPROOT]); + msg1(dk[DK_QDEPTH], dk[DK_ADD], dk_num(dk, 0)); + msg1(dk[DK_QROOT], dk[DK_ADD], dk_num(dk, @as(u64, ROOT_TMP))); +} + +/// Walk up to SCAN_BUDGET queued folders from `head`; returns the new head. +fn dk_walk(dk: [128]u64, head: u64, pass: u64) -> u64 { + var h : u64 = head; + var b : u32 = 0; + while (b < SCAN_BUDGET and h < dk_count(dk, dk[DK_QUEUE])) { + const p : u64 = dk_at(dk, dk[DK_QUEUE], h); + const d : u32 = @as(u32, @intCast(dk_get(dk, DK_QDEPTH, h))); + const r : u32 = @as(u32, @intCast(dk_get(dk, DK_QROOT, h))); + h = h + 1; + b = b + 1; + if (dk_exists(dk, msg1(p, dk[DK_APPEND_PATH], dk[DK_GIT]))) { dk_register(dk, p, pass); } + const kids : u64 = msg4(dk[DK_FM], dk[DK_LIST], msg1(dk[DK_URL_CLS], dk[DK_FILE_URL], p), 0, 0, 0); + if (kids != 0) { + const nk : u64 = dk_count(dk, kids); + var k : u64 = 0; + while (k < nk) { + const u : u64 = dk_at(dk, kids, k); + if (msg_test(u, dk[DK_IS_DIR])) { + const name : u64 = msg0(u, dk[DK_LAST]); + const skipped : bool = msg_test1(dk[DK_SKIP], dk[DK_CONTAINS], name); + const hidden : bool = msg_test1(name, dk[DK_PREFIX], dk[DK_DOT]); + const allowed : bool = msg_test1(dk[DK_ALLOWED], dk[DK_CONTAINS], name); + if (enters(r, d + 1, skipped, hidden, allowed)) { + msg1(dk[DK_QUEUE], dk[DK_ADD], msg1(p, dk[DK_APPEND_PATH], name)); + msg1(dk[DK_QDEPTH], dk[DK_ADD], dk_num(dk, @as(u64, d + 1))); + msg1(dk[DK_QROOT], dk[DK_ADD], dk_num(dk, @as(u64, r))); + } + } + k = k + 1; + } + } + } + return h; +} + +/// End of a pass: forget every repository it did not see; returns how many went. +fn dk_sweep(dk: [128]u64, pass: u64) -> u64 { + var gone : u64 = 0; + var i : u64 = dk_count(dk, dk[DK_PATHS]); + while (i > 0) { + i = i - 1; + if (dk_get(dk, DK_MARK, i) != pass) { + dk_drop(dk, i); + gone = gone + 1; + } + } + msg0(dk[DK_QUEUE], dk[DK_CLEAR]); + msg0(dk[DK_QDEPTH], dk[DK_CLEAR]); + msg0(dk[DK_QROOT], dk[DK_CLEAR]); + return gone; +} + +/// Start job j on repository p in output slot `slot`; returns the task, 0 when the output file cannot open. +fn dk_launch(dk: [128]u64, slot: u64, p: u64, j: u32) -> u64 { + const out : u64 = dk_at(dk, dk[DK_OUTS], slot); + msg3(dk[DK_FM], dk[DK_CREATE], out, 0, 0); + const fh : u64 = msg1(dk[DK_FH_CLS], dk[DK_FH_WRITE], out); + if (fh == 0) { return 0; } + const line : u64 = dk_at(dk, dk[DK_JOBS], @as(u64, j)); + const words : u64 = msg1(msg2(line, dk[DK_REPL], dk[DK_MARKP], p), dk[DK_SPLIT], dk[DK_ARGSEP]); + const nw : u64 = dk_count(dk, words); + const t : u64 = msg0(dk[DK_TASK_CLS], dk[DK_NEW]); + msg1(t, dk[DK_SET_LAUNCH], dk_at(dk, words, 0)); + msg1(t, dk[DK_SET_ARGS], msg2(words, dk[DK_SUB], 1, nw - 1)); + msg1(t, dk[DK_SET_OUT], fh); + msg1(t, dk[DK_SET_ERR], dk[DK_DEVNULL]); + msg1(t, dk[DK_SET_IN], dk[DK_DEVNULL]); + msg0(t, dk[DK_LAUNCH]); + msg0(fh, dk[DK_CLOSE]); + msg2(dk[DK_TPATH], dk[DK_REPLACE], slot, p); + msg2(dk[DK_TJOB], dk[DK_REPLACE], slot, dk_num(dk, @as(u64, j))); + return t; +} + +/// A finished job in slot `slot` exited with `code`: read its output into the repository's columns. +fn dk_take(dk: [128]u64, slot: u64, code: u64) -> void { + const p : u64 = dk_at(dk, dk[DK_TPATH], slot); + const j : u32 = @as(u32, @intCast(dk_get(dk, DK_TJOB, slot))); + const i : u64 = msg1(dk[DK_PATHS], dk[DK_INDEX], p); + if (i == NS_NOT_FOUND) { return; } + const out : u64 = msg3(dk[DK_STR_CLS], dk[DK_READ], dk_at(dk, dk[DK_OUTS], slot), NS_UTF8, 0); + if (out == 0) { return; } + var f : u32 = dk_flags(dk, i); + if (j == JOB_STATUS and code == 0) { + f = with_flag(f, F_CHECKED, true); + const lines : u64 = msg1(out, dk[DK_SPLIT], dk[DK_NL]); + const nl : u64 = dk_count(dk, lines); + var dirty : bool = false; + var stash : bool = false; + var k : u64 = 0; + while (k < nl) { + const ln : u64 = dk_at(dk, lines, k); + if (msg_test1(ln, dk[DK_PREFIX], dk[DK_STHEAD])) { + msg2(dk[DK_BRANCH], dk[DK_REPLACE], i, msg1(ln, dk[DK_FROM], dk_len(dk, dk[DK_STHEAD]))); + } else if (msg_test1(ln, dk[DK_PREFIX], dk[DK_STSTASH])) { + stash = true; + } else if (msg_test1(ln, dk[DK_PREFIX], dk[DK_STH]) == false and dk_len(dk, ln) > 0) { + dirty = true; + } + k = k + 1; + } + f = with_flag(f, F_DIRTY, dirty); + f = with_flag(f, F_STASH, stash); + } + if (j == JOB_UNPUSHED and code == 0) { + f = with_flag(f, F_PUSH_CHECKED, true); + f = with_flag(f, F_UNPUSHED, dk_len(dk, msg1(out, dk[DK_TRIM], dk[DK_WHITE])) > 0); + } + if (j == JOB_REMOTE) { + if (code == 0) { + msg2(dk[DK_REMOTE], dk[DK_REPLACE], i, msg1(out, dk[DK_TRIM], dk[DK_WHITE])); + } else { + msg2(dk[DK_REMOTE], dk[DK_REPLACE], i, dk[DK_EMPTY]); + } + f = with_flag(f, F_NO_REMOTE, code != 0); + } + if (j == JOB_SIZE) { + dk_set(dk, DK_KB, i, msg0(out, dk[DK_LONG])); + } + if (j == JOB_BUILD) { + const lines : u64 = msg1(out, dk[DK_SPLIT], dk[DK_NL]); + const nl : u64 = dk_count(dk, lines); + var sum : u64 = 0; + var k : u64 = 0; + while (k < nl) { + sum = sum + msg0(dk_at(dk, lines, k), dk[DK_LONG]); + k = k + 1; + } + dk_set(dk, DK_BUILD, i, sum); + } + dk_put_flags(dk, i, f); +} + +/// Run job j on repository i now and wait for it (the confirm-time re-check). +fn dk_run_now(dk: [128]u64, i: u64, j: u32) -> void { + const t : u64 = dk_launch(dk, SYNC_SLOT, dk_at(dk, dk[DK_PATHS], i), j); + if (t == 0) { return; } + msg0(t, dk[DK_WAIT]); + dk_take(dk, SYNC_SLOT, msg0(t, dk[DK_STATUS]) % 4294967296); + msg0(t, dk[DK_RELEASE]); +} + +/// A folder name with its copy suffix stripped. +fn dk_stem(dk: [128]u64, name: u64) -> u64 { + return msg5(name, dk[DK_RX_REPL], dk[DK_STEMRX], dk[DK_EMPTY], RX_SEARCH, 0, dk_len(dk, name)); +} + +fn dk_stem_key(dk: [128]u64, p: u64) -> u64 { + return msg1(msg0(p, dk[DK_PARENT]), dk[DK_APPEND_PATH], dk_stem(dk, msg0(p, dk[DK_LAST]))); +} + +/// The derived flags and the DOCTOR row order, from the columns; returns how many rows can go. +fn dk_recompute(dk: [128]u64) -> u32 { + const n : u64 = dk_count(dk, dk[DK_PATHS]); + const keepers : u64 = msg0(dk[DK_DICT_CLS], dk[DK_NEW]); + const stems : u64 = msg0(dk[DK_CSET_CLS], dk[DK_NEW]); + var i : u64 = 0; + while (i < n) { + const p : u64 = dk_at(dk, dk[DK_PATHS], i); + var f : u32 = dk_flags(dk, i); + f = with_flag(f, F_DUP, false); + f = with_flag(f, F_REPEATED, false); + f = with_flag(f, F_HEAVY, heavy(dk_get(dk, DK_KB, i))); + f = with_flag(f, F_BUILD, build_heavy(dk_get(dk, DK_BUILD, i))); + f = with_flag(f, F_BOUND, msg_test1(dk[DK_SLOTS], dk[DK_CONTAINS], p)); + f = with_flag(f, F_HIDDEN, msg_test1(dk[DK_HIDDEN], dk[DK_CONTAINS], p)); + dk_put_flags(dk, i, f); + const rem : u64 = dk_at(dk, dk[DK_REMOTE], i); + if (dup_candidate(f) and dk_len(dk, rem) > 0) { + const b : u64 = msg1(keepers, dk[DK_OBJ_FOR], rem); + if (b == 0) { + msg2(keepers, dk[DK_SET_OBJ], dk_num(dk, i), rem); + } else if (keeper_rank(f) < keeper_rank(dk_flags(dk, msg0(b, dk[DK_VAL])))) { + msg2(keepers, dk[DK_SET_OBJ], dk_num(dk, i), rem); + } + } + msg1(stems, dk[DK_ADD], dk_stem_key(dk, p)); + i = i + 1; + } + i = 0; + while (i < n) { + const p : u64 = dk_at(dk, dk[DK_PATHS], i); + var f : u32 = dk_flags(dk, i); + const rem : u64 = dk_at(dk, dk[DK_REMOTE], i); + if (dup_candidate(f) and dk_len(dk, rem) > 0) { + f = with_flag(f, F_DUP, msg0(msg1(keepers, dk[DK_OBJ_FOR], rem), dk[DK_VAL]) != i); + } + const name : u64 = msg0(p, dk[DK_LAST]); + const suffixed : bool = msg_test1(name, dk[DK_SAME], dk_stem(dk, name)) == false; + const cnt : u64 = msg1(stems, dk[DK_CSET_COUNT], dk_stem_key(dk, p)); + f = with_flag(f, F_REPEATED, made_again(@as(u32, @intCast(cnt)), suffixed)); + dk_put_flags(dk, i, f); + i = i + 1; + } + msg0(keepers, dk[DK_RELEASE]); + msg0(stems, dk[DK_RELEASE]); + msg0(dk[DK_ROWS], dk[DK_CLEAR]); + var go : u32 = 0; + var g : u32 = 0; + while (g < ROW_GROUPS) { + i = 0; + while (i < n) { + const f : u32 = dk_flags(dk, i); + if (row_group(f) == g) { + msg1(dk[DK_ROWS], dk[DK_ADD], dk_num(dk, i)); + if (g == ROW_REMOVABLE) { go = go + 1; } + } + i = i + 1; + } + g = g + 1; + } + return go; +} + +/// Bind space slot s to the first repository not in a slot whose bind rank is at most `most`; whether one was. +fn dk_bind(dk: [128]u64, s: u32, most: u32) -> bool { + const n : u64 = dk_count(dk, dk[DK_PATHS]); + var rank : u32 = RANK_AUTO; + while (rank <= most) { + var i : u64 = 0; + while (i < n) { + const p : u64 = dk_at(dk, dk[DK_PATHS], i); + var f : u32 = dk_flags(dk, i); + f = with_flag(f, F_HIDDEN, msg_test1(dk[DK_HIDDEN], dk[DK_CONTAINS], p)); + if (bind_rank(f) == rank and msg_test1(dk[DK_SLOTS], dk[DK_CONTAINS], p) == false) { + msg2(dk[DK_SLOTS], dk[DK_REPLACE], @as(u64, s), p); + return true; + } + i = i + 1; + } + rank = rank + 1; + } + return false; +} + +/// The sidebar names and branches of the six slots: the bound folder's name and branch, else the seed words. +fn dk_names(dk: [128]u64, names: u64, branches: u64) -> void { + var s : u64 = 0; + while (s < @as(u64, SPACE_COUNT)) { + const p : u64 = dk_at(dk, dk[DK_SLOTS], s); + if (dk_len(dk, p) > 0) { + msg2(names, dk[DK_REPLACE], s, msg0(p, dk[DK_LAST])); + const i : u64 = msg1(dk[DK_PATHS], dk[DK_INDEX], p); + if (i == NS_NOT_FOUND) { + msg2(branches, dk[DK_REPLACE], s, dk[DK_EMPTY]); + } else { + msg2(branches, dk[DK_REPLACE], s, dk_at(dk, dk[DK_BRANCH], i)); + } + } else { + msg2(names, dk[DK_REPLACE], s, dk_at(dk, dk[DK_SEEDN], s)); + msg2(branches, dk[DK_REPLACE], s, dk_at(dk, dk[DK_SEEDB], s)); + } + s = s + 1; + } +} + +/// Move repository i to the Trash if it is still removable after re-checking it now; whether it went. +fn dk_trash(dk: [128]u64, i: u64) -> bool { + // forget the earlier answers first, so a check that fails now keeps the folder as "checking" + dk_put_flags(dk, i, with_flag(with_flag(dk_flags(dk, i), F_CHECKED, false), F_PUSH_CHECKED, false)); + dk_run_now(dk, i, JOB_STATUS); + dk_run_now(dk, i, JOB_UNPUSHED); + if (removable(dk_flags(dk, i)) == false) { return false; } + const url : u64 = msg1(dk[DK_URL_CLS], dk[DK_FILE_URL], dk_at(dk, dk[DK_PATHS], i)); + if (msg3(dk[DK_FM], dk[DK_TRASH], url, 0, 0) % 256 != 1) { return false; } + dk_drop(dk, i); + return true; +} + +/// "12 MB" or "1.5 GB". +fn dk_size(dk: [128]u64, kb: u64) -> u64 { + const whole : u64 = msg0(dk_num(dk, size_whole(kb)), dk[DK_STRVAL]); + if (size_in_gb(kb)) { + const t : u64 = msg1(msg1(whole, dk[DK_APPEND], dk[DK_POINT]), dk[DK_APPEND], msg0(dk_num(dk, size_tenth(kb)), dk[DK_STRVAL])); + return msg1(msg1(t, dk[DK_APPEND], dk[DK_SPACE]), dk[DK_APPEND], dk_at(dk, dk[DK_WORDS], @as(u64, DL_GB))); + } + return msg1(msg1(whole, dk[DK_APPEND], dk[DK_SPACE]), dk[DK_APPEND], dk_at(dk, dk[DK_WORDS], @as(u64, DL_MB))); +} + +/// The flag words a row prints, joined with ", ". +fn dk_flag_words(dk: [128]u64, f: u32) -> u64 { + var s : u64 = dk[DK_EMPTY]; + var k : u32 = 0; + while (k < FLAG_BITS) { + if (shows_word(f, k)) { + if (dk_len(dk, s) > 0) { s = msg1(s, dk[DK_APPEND], dk[DK_COMMA]); } + s = msg1(s, dk[DK_APPEND], dk_at(dk, dk[DK_FWORDS], @as(u64, k))); + } + k = k + 1; + } + return s; +} + +fn dk_word(dk: [128]u64, k: u32) -> u64 { + return dk_at(dk, dk[DK_WORDS], @as(u64, k)); +} + +/// "n" as a string. +fn dk_int(dk: [128]u64, n: u64) -> u64 { + return msg0(dk_num(dk, n), dk[DK_STRVAL]); +} + +fn dk_cat(dk: [128]u64, a: u64, b: u64) -> u64 { + return msg1(a, dk[DK_APPEND], b); +} + +/// The repository whose remove button is under (x, y) in the DOCTOR panel, NS_NOT_FOUND when none is. +fn dk_btn_at(dk: [128]u64, x: f64, y: f64, sw: f64, sh: f64, off: u32) -> u64 { + const l : f64 = qp_left(sw); + const rows : u32 = doc_rows(sh, WS_TOP); + const total : u64 = dk_count(dk, dk[DK_ROWS]); + var r : u32 = 0; + while (r < rows and @as(u64, off + r) < total) { + const i : u64 = dk_get(dk, DK_ROWS, @as(u64, off + r)); + if (removable(dk_flags(dk, i)) and on_doc_btn(x, y, l, QP_W, QP_PAD, WS_TOP, r)) { return i; } + r = r + 1; + } + return NS_NOT_FOUND; +} + +/// W. DOCTOR n at the right end of the sidebar foot (doctor.t27 on_doc_link). +pub fn on_doctor_link(x: f64, y: f64, sh: f64, ws: u32, collapsed: bool) -> bool { + return in_side(x, ws, collapsed) and on_doc_link(x, y, SIDE_W, sh, FOOT_H); +} + +/// D. The DOCTOR panel, where the Queen panel sits: the summary, then one row per repository worth naming -- +/// the ones that can go first, each with its own remove button that asks once more before it moves the +/// folder to the Trash. +fn draw_doctor_panel(c: u64, kit: [48]u64, dk: [128]u64, sw: f64, sh: f64, mx: f64, my: f64, armed: u32, off: u32, go: u32, live: bool) -> void { + const l : f64 = qp_left(sw); + const t : f64 = WS_TOP; + const x : f64 = l + QP_PAD; + cg_fill_rgba(c, 0.0, 0.0, 0.0, 0.94); + cg_fill_rect(c, l, t, QP_W, sh - t); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, l, t, HAIR, sh - t); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + draw_text(c, kit, dk_word(dk, DL_TITLE), x, t + DOC_HEAD_Y, 11.5, WEIGHT_SEMIBOLD, false); + draw_close_x(c, sw - QP_PAD - 8.0, t + 20.0, on_panel_close(mx, my, sw, t), 1.0); + // the summary: how many repositories, how many answered both checks, how many can go, and the walk + const n : u64 = dk_count(dk, dk[DK_PATHS]); + var checked : u64 = 0; + var i : u64 = 0; + while (i < n) { + const f : u32 = dk_flags(dk, i); + if (has(f, F_CHECKED) and has(f, F_PUSH_CHECKED)) { checked = checked + 1; } + i = i + 1; + } + var s : u64 = dk_cat(dk, dk_cat(dk, dk_int(dk, n), dk[DK_SPACE]), dk_word(dk, DL_REPOS)); + s = dk_cat(dk, dk_cat(dk, dk_cat(dk, s, dk[DK_COMMA]), dk_int(dk, checked)), dk[DK_SPACE]); + s = dk_cat(dk, s, dk_word(dk, DL_CHECKED)); + s = dk_cat(dk, dk_cat(dk, dk_cat(dk, s, dk[DK_COMMA]), dk_int(dk, @as(u64, go))), dk[DK_SPACE]); + s = dk_cat(dk, s, dk_word(dk, DL_CAN_GO)); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, s, x, t + DOC_SUM_Y, 10.0, 0.0, false); + var phase : u32 = DL_WATCHING; + if (live) { phase = DL_SCANNING; } + draw_text(c, kit, dk_word(dk, phase), x, t + DOC_SUM_Y + 16.0, 9.5, 0.0, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, x, t + DOC_ROWS_Y - 6.0, QP_W - 2.0 * QP_PAD, HAIR); + const total : u64 = dk_count(dk, dk[DK_ROWS]); + if (total == 0) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, dk_word(dk, DL_NOTHING), x, doc_row_top(t, 0) + 16.0, 10.0, 0.0, false); + return; + } + const rows : u32 = doc_rows(sh, t); + const bx : f64 = doc_btn_x(l, QP_W, QP_PAD); + var r : u32 = 0; + while (r < rows and @as(u64, off + r) < total) { + const ri : u64 = dk_get(dk, DK_ROWS, @as(u64, off + r)); + const f : u32 = dk_flags(dk, ri); + const p : u64 = dk_at(dk, dk[DK_PATHS], ri); + const y : f64 = doc_row_top(t, r); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + const nw : f64 = draw_text(c, kit, msg0(p, dk[DK_LAST]), x, y + 14.0, 11.5, WEIGHT_SEMIBOLD, false); + const kb : u64 = dk_get(dk, DK_KB, ri); + if (kb > 0) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, dk_size(dk, kb), x + nw + 8.0, y + 14.0, 9.5, 0.0, true); + } + // why it is named: what keeps it, or the words for what it is + var why : u64 = dk_flag_words(dk, f); + if (leftover(f) and keep_reason(f) != KEEP_NONE) { why = dk_at(dk, dk[DK_KWORDS], @as(u64, keep_reason(f))); } + cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); + draw_text(c, kit, why, x, y + 29.0, 9.5, 0.0, false); + cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); + draw_text(c, kit, msg0(p, dk[DK_TILDE]), x, y + 42.0, 8.5, 0.0, true); + if (removable(f)) { + const by : f64 = y + DOC_BTN_Y; + const over : bool = on_doc_btn(mx, my, l, QP_W, QP_PAD, t, r); + var word : u32 = DL_REMOVE; + if (armed == @as(u32, @intCast(ri))) { word = DL_CONFIRM; } + // a black plate first, so a long line under the button does not show through it + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + cg_fill_rect(c, bx - 6.0, by - 2.0, DOC_BTN_W + 6.0, DOC_BTN_H + 4.0); + round_rect(c, bx, by, DOC_BTN_W, DOC_BTN_H, 5.0); + if (word == DL_CONFIRM) { + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + cg_fill_path(c); + cg_fill_rgba(c, 0.0, 0.0, 0.0, 1.0); + } else { + cg_line_width(c, 1.0); + cg_stroke_rgba(c, 1.0, 1.0, 1.0, STRICT_HOVER_EDGE); + if (over) { cg_stroke_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); } + cg_stroke_path(c); + cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); + } + const lbl : u64 = dk_word(dk, word); + const lw : f64 = text_w(kit, lbl, 9.5, WEIGHT_SEMIBOLD, false); + draw_text(c, kit, lbl, bx + (DOC_BTN_W - lw) / 2.0, by + 13.5, 9.5, WEIGHT_SEMIBOLD, false); + } + cg_fill_rgba(c, 1.0, 1.0, 1.0, LINE_WHITE); + cg_fill_rect(c, x, y + DOC_ROW_H - 2.0, QP_W - 2.0 * QP_PAD, HAIR); + r = r + 1; + } +} + +// ---- the app ---- + +pub fn main() -> void { + const s_alloc : u64 = sel("alloc"); + const app : u64 = msg0(cls("NSApplication"), sel("sharedApplication")); + msg1(app, sel("setActivationPolicy:"), 0); + const win : u64 = msg_window(msg0(cls("NSWindow"), s_alloc), sel("initWithContentRect:styleMask:backing:defer:"), 0.0, 0.0, W_PT, H_PT, STYLE, BACKING_BUFFERED, false); + var brand_b : [16]u8 = undefined; + const brand_s : u64 = cf_bytes(0, brand_b[0 .. 16], brand(brand_b[0 .. 16]), UTF8, false); + msg1(win, sel("setTitle:"), brand_s); + msg_flag(win, sel("setTitlebarAppearsTransparent:"), true); + msg1(win, sel("setTitleVisibility:"), 1); + msg_flag(win, sel("setMovableByWindowBackground:"), false); + msg_flag(win, sel("setOpaque:"), false); + msg1(win, sel("setBackgroundColor:"), msg0(cls("NSColor"), sel("clearColor"))); + msg1(win, sel("setAppearance:"), msg1(cls("NSAppearance"), sel("appearanceNamed:"), cf("NSAppearanceNameDarkAqua"))); + const glass : u64 = msg_rect(msg0(cls("NSVisualEffectView"), s_alloc), sel("initWithFrame:"), 0.0, 0.0, W_PT, H_PT); + msg1(glass, sel("setMaterial:"), MATERIAL); + msg1(glass, sel("setBlendingMode:"), BLEND_BEHIND); + msg1(glass, sel("setState:"), STATE_ACTIVE); + msg1(win, sel("setContentView:"), glass); + msg1(win, sel("setCollectionBehavior:"), FULL_SCREEN_PRIMARY); + msg1(glass, sel("setAutoresizingMask:"), SIZABLE); + const view : u64 = msg_rect(msg0(cls("NSView"), s_alloc), sel("initWithFrame:"), 0.0, 0.0, W_PT, H_PT); + msg_flag(view, sel("setWantsLayer:"), true); + msg1(glass, sel("addSubview:"), view); + msg1(view, sel("setAutoresizingMask:"), SIZABLE); + const layer : u64 = msg0(view, sel("layer")); + msg1(layer, sel("setContentsGravity:"), cf("resizeAspect")); + // W. the blackGlass tint lives on the layer, so it covers the whole window in full screen too. + msg1(layer, sel("setBackgroundColor:"), cg_color(0.0, 0.0, 0.0, TINT_BLACK)); + msg0(win, sel("center")); + // the smallest window that still shows the sidebar, a chat header and one line of the pane (audit + // 2026-10-19); mouse moves wake the event wait, which grows to FG_IDLE_S while the screen is still + msg_ff(win, sel("setContentMinSize:"), 480.0, 320.0); + msg_flag(win, sel("setAcceptsMouseMovedEvents:"), true); + msg1(win, sel("makeKeyAndOrderFront:"), 0); + msg0(app, sel("finishLaunching")); + // D. the Dock icon, drawn here rather than shipped as a foreign .icns file. + const ic : u64 = cg_bitmap(0, 512, 512, 8, 2048, cg_rgb(), ALPHA_PREMUL_LAST); + cg_translate(ic, 0.0, ICON_SIZE); + cg_scale(ic, 1.0, -1.0); + draw_app_icon(ic); + const icon : u64 = msg_image(msg0(cls("NSImage"), s_alloc), sel("initWithCGImage:size:"), cg_image(ic), ICON_SIZE, ICON_SIZE); + msg1(app, sel("setApplicationIconImage:"), icon); + msg_flag(app, sel("activateIgnoringOtherApps:"), true); + + // the text kit: every class, selector and string the frames need, made once. + const s_split : u64 = sel("componentsSeparatedByString:"); + var kit : [48]u64 = undefined; + kit[K_FONT] = cls("NSFont"); + kit[K_MONO_SEL] = sel("monospacedSystemFontOfSize:weight:"); + kit[K_SYS_SEL] = sel("systemFontOfSize:weight:"); + kit[K_KEY_FONT] = cf("NSFont"); + kit[K_KEY_CTX] = cf("CTForegroundColorFromContext"); + kit[K_YES] = msg_flag(cls("NSNumber"), sel("numberWithBool:"), true); + msg0(kit[K_YES], sel("retain")); + kit[K_DICT] = cls("NSDictionary"); + kit[K_DICT_SEL] = sel("dictionaryWithObjects:forKeys:count:"); + kit[K_ATTR] = cls("NSAttributedString"); + kit[K_ALLOC] = s_alloc; + kit[K_INIT] = sel("initWithString:attributes:"); + kit[K_RELEASE] = sel("release"); + kit[K_NAMES] = msg1(cf(WORLD_NAMES), s_split, cf(",")); + msg0(kit[K_NAMES], sel("retain")); + kit[K_AT] = sel("objectAtIndex:"); + kit[K_TRINITY] = brand_s; + kit[K_QUEEN] = cf("Queen"); + kit[K_CMD] = cf("Cmd+"); + kit[K_OPT] = cf("Option+"); + kit[K_CTRL] = cf("Ctrl+"); + kit[K_DIGITS] = msg1(cf("1,2,3,4,5,6,7,8,9"), s_split, cf(",")); + msg0(kit[K_DIGITS], sel("retain")); + kit[K_OPEN_DASH] = cf("OPEN DASHBOARD"); + kit[K_AGENTS] = cf("AGENTS"); + kit[K_AGENT_NAMES] = msg1(cf(AGENT_NAMES), s_split, cf(",")); + msg0(kit[K_AGENT_NAMES], sel("retain")); + kit[K_SPACES] = cf("SPACES"); + kit[K_QUEEN_LBL] = cf("QUEEN"); + kit[K_CONTROLS] = msg1(cf(CONTROL_NAMES), s_split, cf(",")); + msg0(kit[K_CONTROLS], sel("retain")); + kit[K_AGES] = msg1(cf(AGE_WORDS), s_split, cf(",")); + msg0(kit[K_AGES], sel("retain")); + kit[K_SUGGEST] = msg1(cf(SUGGEST_WORDS), s_split, cf(",")); + msg0(kit[K_SUGGEST], sel("retain")); + kit[K_QP] = msg1(cf(QP_WORDS), s_split, cf(",")); + msg0(kit[K_QP], sel("retain")); + // D. mutable: the DOCTOR writes the bound repositories' names and branches into them + kit[K_SPACE_NAMES] = msg0(msg1(cf(SPACE_NAMES), s_split, cf(",")), sel("mutableCopy")); + msg0(kit[K_SPACE_NAMES], sel("retain")); + kit[K_BRANCHES] = msg0(msg1(cf(SPACE_BRANCHES), s_split, cf(",")), sel("mutableCopy")); + msg0(kit[K_BRANCHES], sel("retain")); + kit[K_STATE_WORDS] = msg1(cf(STATE_WORDS), s_split, cf(",")); + msg0(kit[K_STATE_WORDS], sel("retain")); + kit[K_EMPTY] = cf("Tap a cell to open a session"); + kit[K_REPO] = cf(REPO_LINK_TEXT); + kit[K_MDICT] = cls("NSMutableDictionary"); + kit[K_NEW] = sel("new"); + kit[K_CACHE] = msg0(kit[K_MDICT], kit[K_NEW]); + kit[K_GET] = sel("objectForKey:"); + kit[K_PUT] = sel("setObject:forKey:"); + kit[K_COUNT] = sel("count"); + kit[K_FLUSH] = sel("removeAllObjects"); + kit[K_NUM_CLS] = cls("NSNumber"); + kit[K_NUM] = sel("numberWithUnsignedLongLong:"); + var ell_buf : [1]u64 = undefined; + ell_buf[0] = 8230; + kit[K_ELL] = msg_chars(msg0(cls("NSString"), s_alloc), sel("initWithCharacters:length:"), ell_buf, 1); + + const c : u64 = cg_bitmap(0, 3840, 2400, 8, 15360, cg_rgb(), ALPHA_PREMUL_LAST); + cg_translate(c, 0.0, SH_MAX * WIDE_RETINA); + cg_scale(c, WIDE_RETINA, 0.0 - WIDE_RETINA); + // F. the window tint and the full-screen black glass, swapped when the style mask changes. + const tint_win : u64 = cg_color(0.0, 0.0, 0.0, TINT_BLACK); + const tint_full : u64 = cg_color(0.0, 0.0, 0.0, TINT_FULL); + var was_full : bool = false; + // K. the narrow panel draws point for point into its own bitmap, cropped to the view each frame. + const cc : u64 = cg_bitmap(0, 3040, 5200, 8, 12160, cg_rgb(), ALPHA_PREMUL_LAST); + cg_translate(cc, 0.0, CH_MAX * RETINA); + cg_scale(cc, RETINA, 0.0 - RETINA); + + const s_next : u64 = sel("nextEventMatchingMask:untilDate:inMode:dequeue:"); + const s_send : u64 = sel("sendEvent:"); + const s_type : u64 = sel("type"); + const s_key : u64 = sel("keyCode"); + const s_flags : u64 = sel("modifierFlags"); + const s_value : u64 = sel("valueForKey:"); + const s_get : u64 = sel("getValue:"); + const s_visible : u64 = sel("isVisible"); + const s_mask : u64 = sel("styleMask"); + const s_contents : u64 = sel("setContents:"); + const s_wait : u64 = sel("dateWithTimeIntervalSinceNow:"); + const s_quiet : u64 = sel("setDisableActions:"); + const k_mouse : u64 = cf("mouseLocationOutsideOfEventStream"); + const k_click : u64 = cf("locationInWindow"); + const mode : u64 = cf("kCFRunLoopDefaultMode"); + const date_cls : u64 = cls("NSDate"); + const tx_cls : u64 = cls("CATransaction"); + const k_frame : u64 = cf("frame"); + const s_full : u64 = sel("toggleFullScreen:"); + const s_set_frame : u64 = sel("setFrame:display:animate:"); + const s_screen : u64 = sel("screen"); + const k_visible : u64 = cf("visibleFrame"); + const s_cursor_set : u64 = sel("set"); + const hand : u64 = msg0(cls("NSCursor"), sel("pointingHandCursor")); + const arrow : u64 = msg0(cls("NSCursor"), sel("arrowCursor")); + const sc : i64 = fit_permille(960, 540); + + var pt : [2]f64 = undefined; + var fr : [4]f64 = undefined; + var wf : [4]f64 = undefined; + var vf : [4]f64 = undefined; + var docked : bool = false; + var dash : bool = false; + var state : u32 = STATE_HOME; + var shown : u32 = 0; + var ma : f64 = 0.0; + var collapsed : bool = false; + var frame : u32 = 0; + var task : u32 = CARD_SLOTS; + var space : u32 = 0; + var ss : u64 = 0; + var sp : u32 = SPACES_SEED; + var cur : u32 = SESSION_NONE; + // Q. the Queen panel (queen.t27): open, GOD MODE, the ring of recent events, a dismissed suggestion + var qp : bool = false; + var god : bool = false; + var ring : [8]u64 = undefined; + var ri : u32 = 0; + while (ri < RING) { + ring[ri] = 0; + ri = ri + 1; + } + var evn : u32 = 0; + var dismissed : u32 = RING_NONE; + var last_user : u64 = 0; + var last_act : u64 = 0; + var now : u64 = unix_time(0); + var glow : f64 = 0.0; + // R. hot reload (hot_reload.t27): the executable, its date now, and the state a swap left behind + const defs : u64 = msg0(cls("NSUserDefaults"), sel("standardUserDefaults")); + const k_hot : u64 = cf("t27os.hot"); + const k_state : u64 = cf("t27os.state"); + const k_shown : u64 = cf("t27os.shown"); + const k_task : u64 = cf("t27os.task"); + const k_space : u64 = cf("t27os.space"); + // sessions2: the session byte became 1 + agent * 8 + space when spaces grew to six slots + const k_ss : u64 = cf("t27os.sessions2"); + const k_sp : u64 = cf("t27os.spaces"); + const k_cur : u64 = cf("t27os.cur"); + const k_flags : u64 = cf("t27os.flags"); + const k_evn : u64 = cf("t27os.evn"); + var k_ev : [8]u64 = undefined; + k_ev[0] = cf("t27os.ev0"); + k_ev[1] = cf("t27os.ev1"); + k_ev[2] = cf("t27os.ev2"); + k_ev[3] = cf("t27os.ev3"); + k_ev[4] = cf("t27os.ev4"); + k_ev[5] = cf("t27os.ev5"); + k_ev[6] = cf("t27os.ev6"); + k_ev[7] = cf("t27os.ev7"); + // the same keys outlive the process (window_keep.t27): t27os.kept says they hold a state to restore + const k_kept : u64 = cf("t27os.kept"); + var wk_saved : u64 = 0; + var wk_last : u64 = 0; + var wk_n : u32 = 0; + const exe : u64 = msg0(msg0(cls("NSBundle"), sel("mainBundle")), sel("executablePath")); + msg0(exe, sel("retain")); + const files : u64 = msg0(cls("NSFileManager"), sel("defaultManager")); + const s_attrs : u64 = sel("attributesOfItemAtPath:error:"); + const s_mdate : u64 = sel("fileModificationDate"); + const s_same : u64 = sel("isEqualToDate:"); + var seen : u64 = msg0(msg2(files, s_attrs, exe, 0), s_mdate); + msg0(seen, sel("retain")); + var waited : u32 = RELOAD_IDLE; + // D. the DOCTOR (doctor.t27): every handle the walk, the checks and the panel use, made once here + var dk : [128]u64 = undefined; + var di : u32 = 0; + while (di < DK_SIZE) { + dk[di] = 0; + di = di + 1; + } + const s_retain : u64 = sel("retain"); + const marr : u64 = cls("NSMutableArray"); + dk[DK_NEW] = sel("new"); + dk[DK_RELEASE] = sel("release"); + dk[DK_COUNT] = sel("count"); + dk[DK_AT] = sel("objectAtIndex:"); + dk[DK_ADD] = sel("addObject:"); + dk[DK_REMOVE_AT] = sel("removeObjectAtIndex:"); + dk[DK_REPLACE] = sel("replaceObjectAtIndex:withObject:"); + dk[DK_INDEX] = sel("indexOfObject:"); + dk[DK_CONTAINS] = sel("containsObject:"); + dk[DK_NUM] = sel("numberWithUnsignedLongLong:"); + dk[DK_VAL] = sel("unsignedLongLongValue"); + dk[DK_APPEND_PATH] = sel("stringByAppendingPathComponent:"); + dk[DK_APPEND] = sel("stringByAppendingString:"); + dk[DK_EXISTS] = sel("fileExistsAtPath:"); + dk[DK_PREFIX] = sel("hasPrefix:"); + dk[DK_RANGE] = sel("rangeOfString:"); + dk[DK_LAST] = sel("lastPathComponent"); + dk[DK_LIST] = sel("contentsOfDirectoryAtURL:includingPropertiesForKeys:options:error:"); + dk[DK_FILE_URL] = sel("fileURLWithPath:"); + dk[DK_URL_PATH] = sel("path"); + dk[DK_IS_DIR] = sel("hasDirectoryPath"); + dk[DK_CLEAR] = sel("removeAllObjects"); + dk[DK_RUNNING] = sel("isRunning"); + dk[DK_STATUS] = sel("terminationStatus"); + dk[DK_LAUNCH] = sel("launch"); + dk[DK_SET_LAUNCH] = sel("setLaunchPath:"); + dk[DK_SET_ARGS] = sel("setArguments:"); + dk[DK_SET_OUT] = sel("setStandardOutput:"); + dk[DK_SET_ERR] = sel("setStandardError:"); + dk[DK_SET_IN] = sel("setStandardInput:"); + dk[DK_REPL] = sel("stringByReplacingOccurrencesOfString:withString:"); + dk[DK_SPLIT] = sel("componentsSeparatedByString:"); + dk[DK_SUB] = sel("subarrayWithRange:"); + dk[DK_READ] = sel("stringWithContentsOfFile:encoding:error:"); + dk[DK_CREATE] = sel("createFileAtPath:contents:attributes:"); + dk[DK_FH_WRITE] = sel("fileHandleForWritingAtPath:"); + dk[DK_CLOSE] = sel("closeFile"); + dk[DK_LONG] = sel("longLongValue"); + dk[DK_TRASH] = sel("trashItemAtURL:resultingItemURL:error:"); + dk[DK_LENGTH] = sel("length"); + dk[DK_STRVAL] = sel("stringValue"); + dk[DK_TRIM] = sel("stringByTrimmingCharactersInSet:"); + dk[DK_PARENT] = sel("stringByDeletingLastPathComponent"); + dk[DK_RX_REPL] = sel("stringByReplacingOccurrencesOfString:withString:options:range:"); + dk[DK_CSET_COUNT] = sel("countForObject:"); + dk[DK_OBJ_FOR] = sel("objectForKey:"); + dk[DK_SET_OBJ] = sel("setObject:forKey:"); + dk[DK_WAIT] = sel("waitUntilExit"); + dk[DK_TILDE] = sel("stringByAbbreviatingWithTildeInPath"); + dk[DK_SAME] = sel("isEqualToString:"); + dk[DK_FROM] = sel("substringFromIndex:"); + dk[DK_NUMBER] = cls("NSNumber"); + dk[DK_URL_CLS] = cls("NSURL"); + dk[DK_STR_CLS] = cls("NSString"); + dk[DK_FH_CLS] = cls("NSFileHandle"); + dk[DK_TASK_CLS] = cls("NSTask"); + dk[DK_DICT_CLS] = cls("NSMutableDictionary"); + dk[DK_CSET_CLS] = cls("NSCountedSet"); + dk[DK_FM] = files; + dk[DK_NULL] = msg0(cls("NSNull"), sel("null")); + dk[DK_EMPTY] = cf(""); + dk[DK_GIT] = cf(".git"); + dk[DK_HEADF] = cf("HEAD"); + dk[DK_WTS] = cf("worktrees"); + dk[DK_DOT] = cf(HIDDEN_PREFIX); + dk[DK_SLASH] = cf("/"); + dk[DK_NL] = cf("\n"); + dk[DK_MARKP] = cf(PATH_MARK); + dk[DK_ARGSEP] = cf(ARG_SEP); + dk[DK_STH] = cf(ST_HEADER); + dk[DK_STHEAD] = cf(ST_HEAD); + dk[DK_STSTASH] = cf(ST_STASH); + dk[DK_STEMRX] = cf(STEM_PATTERN); + dk[DK_COMMA] = cf(", "); + dk[DK_SPACE] = cf(" "); + dk[DK_POINT] = cf("."); + dk[DK_TMPROOT] = cf(ROOT_TMP_PATH); + dk[DK_HOME] = home_dir(); + msg0(dk[DK_HOME], s_retain); + dk[DK_DEVNULL] = msg0(dk[DK_FH_CLS], sel("fileHandleWithNullDevice")); + msg0(dk[DK_DEVNULL], s_retain); + dk[DK_WHITE] = msg0(cls("NSCharacterSet"), sel("whitespaceAndNewlineCharacterSet")); + msg0(dk[DK_WHITE], s_retain); + dk[DK_SKIP] = msg1(cls("NSSet"), sel("setWithArray:"), msg1(cf(SCAN_SKIP), s_split, cf(","))); + msg0(dk[DK_SKIP], s_retain); + dk[DK_ALLOWED] = msg1(cls("NSSet"), sel("setWithArray:"), msg1(cf(HIDDEN_ALLOWED), s_split, cf(","))); + msg0(dk[DK_ALLOWED], s_retain); + dk[DK_AGENTS] = msg1(cf(AGENT_ROOTS), s_split, cf(",")); + msg0(dk[DK_AGENTS], s_retain); + dk[DK_JOBS] = msg1(cf(JOB_LINES), s_split, cf(JOB_SEP)); + msg0(dk[DK_JOBS], s_retain); + dk[DK_WORDS] = msg1(cf(DOC_WORDS), s_split, cf(",")); + msg0(dk[DK_WORDS], s_retain); + dk[DK_FWORDS] = msg1(cf(FLAG_WORDS), s_split, cf(",")); + msg0(dk[DK_FWORDS], s_retain); + dk[DK_KWORDS] = msg1(cf(KEEP_WORDS), s_split, cf(",")); + msg0(dk[DK_KWORDS], s_retain); + dk[DK_SEEDN] = msg1(cf(SPACE_NAMES), s_split, cf(",")); + msg0(dk[DK_SEEDN], s_retain); + dk[DK_SEEDB] = msg1(cf(SPACE_BRANCHES), s_split, cf(",")); + msg0(dk[DK_SEEDB], s_retain); + dk[DK_PATHS] = msg0(marr, dk[DK_NEW]); + dk[DK_FLAGS] = msg0(marr, dk[DK_NEW]); + dk[DK_KB] = msg0(marr, dk[DK_NEW]); + dk[DK_BUILD] = msg0(marr, dk[DK_NEW]); + dk[DK_REMOTE] = msg0(marr, dk[DK_NEW]); + dk[DK_BRANCH] = msg0(marr, dk[DK_NEW]); + dk[DK_MARK] = msg0(marr, dk[DK_NEW]); + dk[DK_QUEUE] = msg0(marr, dk[DK_NEW]); + dk[DK_QDEPTH] = msg0(marr, dk[DK_NEW]); + dk[DK_QROOT] = msg0(marr, dk[DK_NEW]); + dk[DK_ROWS] = msg0(marr, dk[DK_NEW]); + dk[DK_TASKS] = dk_array(dk, marr, @as(u64, JOB_SLOTS), dk[DK_NULL]); + dk[DK_TPATH] = dk_array(dk, marr, SYNC_SLOT + 1, dk[DK_EMPTY]); + dk[DK_TJOB] = dk_array(dk, marr, SYNC_SLOT + 1, dk_num(dk, 0)); + dk[DK_OUTS] = msg0(marr, dk[DK_NEW]); + msg1(dk[DK_OUTS], dk[DK_ADD], cf("/private/tmp/t27os-doctor-0.out")); + msg1(dk[DK_OUTS], dk[DK_ADD], cf("/private/tmp/t27os-doctor-1.out")); + msg1(dk[DK_OUTS], dk[DK_ADD], cf("/private/tmp/t27os-doctor-2.out")); + msg1(dk[DK_OUTS], dk[DK_ADD], cf("/private/tmp/t27os-doctor-3.out")); + msg1(dk[DK_OUTS], dk[DK_ADD], cf("/private/tmp/t27os-doctor-4.out")); + // D. the space slots: the repository each of the six is bound to ("" when none), and the ones the user hid + const k_slots : u64 = cf("t27os.slots"); + const k_hidden : u64 = cf("t27os.hidden"); + const s_arr_for : u64 = sel("arrayForKey:"); + const s_mcopy : u64 = sel("mutableCopy"); + const saved : u64 = msg1(defs, s_arr_for, k_slots); + if (saved != 0 and dk_count(dk, saved) == @as(u64, SPACE_COUNT)) { + dk[DK_SLOTS] = msg0(saved, s_mcopy); + } else { + dk[DK_SLOTS] = msg0(marr, dk[DK_NEW]); + const seeds : u64 = msg1(cf(SEED_PATHS), s_split, cf(",")); + var si : u64 = 0; + while (si < @as(u64, SPACE_COUNT)) { + var bound : u64 = dk[DK_EMPTY]; + if (si < dk_count(dk, seeds)) { + const q : u64 = msg1(dk[DK_HOME], dk[DK_APPEND_PATH], dk_at(dk, seeds, si)); + if (dk_exists(dk, msg1(q, dk[DK_APPEND_PATH], dk[DK_GIT]))) { bound = q; } + } + msg1(dk[DK_SLOTS], dk[DK_ADD], bound); + si = si + 1; + } + } + const saved_h : u64 = msg1(defs, s_arr_for, k_hidden); + if (saved_h != 0) { + dk[DK_HIDDEN] = msg0(saved_h, s_mcopy); + } else { + dk[DK_HIDDEN] = msg0(marr, dk[DK_NEW]); + } + sp = 0; + var bs : u32 = 0; + while (bs < SPACE_COUNT) { + if (dk_len(dk, dk_at(dk, dk[DK_SLOTS], @as(u64, bs))) > 0) { sp = add_space(sp, bs); } + bs = bs + 1; + } + if (spaces_shown(sp) == 0) { sp = SPACES_SEED; } + dk_names(dk, kit[K_SPACE_NAMES], kit[K_BRANCHES]); + // the walk: the queue head, the pass number, idle frames since the last pass; the checks' cursor + var walking : bool = false; + var qhead : u64 = 0; + var wpass : u64 = 0; + var widle : u32 = RESCAN_FRAMES; + var vr : u64 = 0; + var jr : u64 = 0; + var jj : u32 = 0; + var jpass : u32 = 0; + var jrest : u32 = 0; + // the panel: open, the armed row (a repository index), the scroll, the sums, a pending recompute + var dp : bool = false; + var darmed : u32 = ARMED_NONE; + var doff : u32 = 0; + var dacc : f64 = 0.0; + var dgo : u32 = 0; + var dnow : bool = true; + var auto_open : bool = true; + // the terminal pane: the vt grid, the session it shows, offline or live, the server's pane id, + // frames since the last key (the cursor stays solid while typing), the bytes in and out, the socket, + // the read cursor into the pane's output and frames since the last connect attempt + var tst : [10]u32 = undefined; + var tcells : [48000]u32 = undefined; + vt_init(tst[0..10], tcells[0..48000], TP_COLS_MIN, TP_ROWS_MIN); + var tkey : [8]u8 = undefined; + var techo : [8]u8 = undefined; + var tout : [4100]u8 = undefined; + var thome : [256]u8 = undefined; + var tdir : [512]u8 = undefined; + var tfd : i32 = -1; + var tcursor : u64 = 0; + var ttry : u32 = 0; + var topen : u32 = PANE_NONE; + var tlink : u32 = PANE_OFFLINE; + var tpid : u32 = PANE_NONE; + var tsince : u32 = 999; + var tcur : bool = false; + // the resize debounce: the grid that fit last frame and how many frames it has held + var twc : u32 = 0; + var twr : u32 = 0; + var tstable : u32 = 0; + // the frame gate (frame_gate.t27): the key on screen, frames since the last draw and since the key + // last changed, a count of grid updates, and the next event wait + var fshown : u64 = 0; + var fsince : u32 = 9999; + var fquiet : u32 = 0; + var tfed : u64 = 0; + var fwait : f64 = FRAME_S; + const s_chars : u64 = sel("characters"); + const s_chars_raw : u64 = sel("charactersIgnoringModifiers"); + const s_char_at : u64 = sel("characterAtIndex:"); + const s_scroll_y : u64 = sel("scrollingDeltaY"); + const s_precise : u64 = sel("hasPreciseScrollingDeltas"); + msg1(win, sel("setFrameAutosaveName:"), cf("T27OS")); + // a hot swap always restores; an ordinary launch restores what was kept unless Option is held + const wk_mods : u64 = msg0(cls("NSEvent"), sel("modifierFlags")); + const wk_hot : bool = msg1(defs, sel("integerForKey:"), k_hot) == 1; + if (!wk_hot and !wk_restore(1, wk_mods)) { msg2(defs, sel("setInteger:forKey:"), 0, k_kept); } + if (wk_hot or wk_restore(msg1(defs, sel("integerForKey:"), k_kept), wk_mods)) { + state = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_state))); + shown = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_shown))); + task = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_task))); + space = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_space))); + ss = msg1(defs, sel("integerForKey:"), k_ss); + sp = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_sp))); + if (spaces_shown(sp) == 0) { sp = SPACES_SEED; } + if (space_is_open(sp, space) == false) { space = space_at_row(sp, 0); } + cur = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_cur))); + if (cur >= session_count(ss)) { cur = SESSION_NONE; } + const fl : u64 = msg1(defs, sel("integerForKey:"), k_flags); + collapsed = fl % 2 == 1; + dash = fl / 2 % 2 == 1; + qp = fl / 4 % 2 == 1; + god = fl / 8 % 2 == 1; + dp = fl / 16 % 2 == 1; + auto_open = fl / 32 % 2 == 0; + evn = @as(u32, @intCast(msg1(defs, sel("integerForKey:"), k_evn))); + ri = 0; + while (ri < RING) { + ring[ri] = msg1(defs, sel("integerForKey:"), k_ev[ri]); + ri = ri + 1; + } + if (menu_open(state)) { ma = 1.0; } + msg2(defs, sel("setInteger:forKey:"), 0, k_hot); + } + while (msg_test(win, s_visible)) { + const pool : u64 = pool_push(); + msg_point(msg1(win, s_value, k_mouse), s_get, pt); + msg_frame(msg1(view, s_value, k_frame), s_get, fr); + const sw : f64 = scene(fr[2], fr[3], 0); + const sh : f64 = scene(fr[2], fr[3], 1); + const narrow : bool = fr[2] < COMPACT_BELOW; + const ws : u32 = workspace(sw, dash); + var mx : f64 = view_to_scene(pt[0], fr[2], fr[3], 0, sw, sh); + var my : f64 = view_to_scene(pt[1], fr[2], fr[3], 1, sw, sh); + const full : bool = has_mod(msg0(win, s_mask), MASK_FULL_SCREEN); + var z : f64 = 1.0; + if (full) { z = FULL_LOGO; } + if (full != was_full) { + if (full) { msg1(layer, sel("setBackgroundColor:"), tint_full); } else { msg1(layer, sel("setBackgroundColor:"), tint_win); } + was_full = full; + } + var vx : i64 = @as(i64, @intFromFloat(virt(mx, 0, z, sw, sh))); + var vy : i64 = @as(i64, @intFromFloat(virt(my, 1, z, sw, sh))); + // H. the logo answers the mouse only at home, once the screen has gone. + const home_live : bool = menu_open(state) == false and ma == 0.0 and my > AREA_TOP; + const screen : bool = menu_open(state) and ma >= 0.5; + now = unix_time(0); + var sug : u32 = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + var ev : u64 = msg_event(app, s_next, 0xffffffffffffffff, msg_f(date_cls, s_wait, fwait), mode, true); + while (ev != 0) { + const ty : u64 = msg0(ev, s_type); + var used : bool = false; + var ctl : u32 = C_NONE; + var carg : u32 = 0; + if (ty == EV_LEFT_DOWN) { + // the click is where the event says, not where the mouse was when the frame began + msg_point(msg1(ev, s_value, k_click), s_get, pt); + mx = view_to_scene(pt[0], fr[2], fr[3], 0, sw, sh); + my = view_to_scene(pt[1], fr[2], fr[3], 1, sw, sh); + vx = @as(i64, @intFromFloat(virt(mx, 0, z, sw, sh))); + vy = @as(i64, @intFromFloat(virt(my, 1, z, sw, sh))); + if (qp and on_panel(mx, my, sw, WS_TOP)) { + // Q. the Queen panel: close, GOD MODE, and the human gate on every suggestion + if (on_panel_close(mx, my, sw, WS_TOP)) { + qp = false; + god = false; + ctl = C_QUEEN_KEY; + } else if (on_god(mx, my, sw, WS_TOP)) { + god = god == false; + if (god) { ctl = C_GOD_ON; } else { ctl = C_GOD_OFF; } + last_act = now; + } else if (sug != A_NONE and on_confirm(mx, my, sw, WS_TOP)) { + cur = act_cur(ss, sug, space, cur); + ss = act_sessions(ss, sug, space); + if (cur != SESSION_NONE) { task = session_agent(ss, cur); carg = task; } + ctl = C_CONFIRM; + } else if (sug != A_NONE and on_dismiss(mx, my, sw, WS_TOP)) { + dismissed = sug; + ctl = C_DISMISS; + } + } else if (dp and on_panel(mx, my, sw, WS_TOP)) { + // D. the DOCTOR panel: close, or a row's remove / confirm -- one item, one human click each + if (on_panel_close(mx, my, sw, WS_TOP)) { + dp = false; + darmed = ARMED_NONE; + } else { + const bi : u64 = dk_btn_at(dk, mx, my, sw, sh, doff); + if (bi != NS_NOT_FOUND) { + const o : u32 = click_on_row(darmed, @as(u32, @intCast(bi)), dk_flags(dk, bi)); + darmed = after_click(o, @as(u32, @intCast(bi))); + if (o == CLICK_REMOVE) { + if (dk_trash(dk, bi)) { dnow = true; } + } + } else { + darmed = ARMED_NONE; + } + } + } else if (on_home_button(mx, my, sw)) { + ctl = C_HOME; + state = on_back(state); + } else if (on_full_button(mx, my, sw)) { + msg1(win, s_full, 0); + } else if (on_dock_button(mx, my, sw) and full == false) { + // K. WindowManager: the panel docks 400 pt wide on the right edge, full visible height. + if (docked) { + msg_set_frame(win, s_set_frame, wf[0], wf[1], wf[2], wf[3], true, true); + } else { + msg_frame(msg1(win, s_value, k_frame), s_get, wf); + msg_frame(msg1(msg0(win, s_screen), s_value, k_visible), s_get, vf); + msg_set_frame(win, s_set_frame, vf[0] + vf[2] - PANEL_W, vf[1], PANEL_W, vf[3], true, true); + } + docked = docked == false; + } else if (screen and on_nav_back(mx, my, full)) { + state = on_back(state); + ctl = C_HOME; + } else if (screen and on_open_dash(mx, my, sw, ws)) { + dash = true; + ctl = C_DASHBOARD; + } else if (screen and on_close_dash(mx, my, ws, collapsed)) { + dash = false; + ctl = C_DASHBOARD; + } else if (screen and on_side_toggle(mx, my, ws, collapsed)) { + collapsed = collapsed == false; + ctl = C_SIDEBAR; + } else if (screen and on_new_chat(mx, my, sw, ws)) { + // N. "+" opens one more chat of the selected agent in this space and selects it + const nk : u32 = new_chat_agent(task); + const before : u32 = session_count(ss); + ss = add_session(ss, nk, space); + if (session_count(ss) > before) { cur = before; task = nk; } + ctl = C_NEW_CHAT; + carg = nk; + } else if (screen and card_at(mx, my, sw, sh, ws, collapsed) != CARD_SLOTS) { + // A. a tap on a cell opens that agent's chat here, or a first one if there is none + task = card_at(mx, my, sw, sh, ws, collapsed); + ctl = C_CELL; + carg = task; + cur = SESSION_NONE; + if (task != AGENT_QUEEN) { + cur = first_of(ss, task, space); + if (cur == SESSION_NONE) { + const before : u32 = session_count(ss); + ss = add_session(ss, task, space); + if (session_count(ss) > before) { cur = before; } + } + } + } else if (screen and in_side(mx, ws, collapsed) and on_space_plus(mx, my) and new_space(sp) != SPACE_NONE) { + // W. "+" opens the next closed space slot and selects it; a chat elsewhere is let go + space = new_space(sp); + sp = add_space(sp, space); + ctl = C_NEW_SPACE; + dk_bind(dk, space, RANK_PLUS); + dnow = true; + carg = space; + if (cur != SESSION_NONE) { cur = SESSION_NONE; task = CARD_SLOTS; } + } else if (screen and on_doctor_link(mx, my, sh, ws, collapsed)) { + // D. DOCTOR in the foot opens or closes the panel; it takes the Queen panel's place + dp = dp == false; + if (dp) { qp = false; god = false; } + darmed = ARMED_NONE; + } else if (screen and on_repo_link(mx, my, sh, ws, collapsed)) { + // W. OPEN SOURCE T27 opens the repository in the default browser + const url : u64 = msg1(cls("NSURL"), sel("URLWithString:"), cf(REPO_URL)); + msg1(msg0(cls("NSWorkspace"), sel("sharedWorkspace")), sel("openURL:"), url); + ctl = C_REPO_LINK; + } else if (screen and in_side(mx, ws, collapsed) and row_at(WS_TOP, GROUP_QUEEN, spaces_shown(sp), 1, my) == 0) { + // W. the Queen row selects the orchestrator and opens or closes her panel + task = AGENT_QUEEN; + cur = SESSION_NONE; + qp = qp == false; + if (qp == false) { god = false; } + ctl = C_QUEEN_ROW; + } else if (screen and in_side(mx, ws, collapsed) and row_at(WS_TOP, GROUP_SPACES, spaces_shown(sp), spaces_shown(sp), my) != ROW_NONE) { + // W. a space row selects the space; a chat that lives elsewhere is let go + const sr : u32 = row_at(WS_TOP, GROUP_SPACES, spaces_shown(sp), spaces_shown(sp), my); + const rs : u32 = space_at_row(sp, sr); + if (spaces_shown(sp) > 1 and on_space_close(mx, my, row_top(WS_TOP, GROUP_SPACES, spaces_shown(sp), sr))) { + // W. its x closes the space and every chat in it; the selection moves to the first space left + ctl = C_CLOSE_SPACE; + carg = rs; + cur = after_drop(cur, ss, rs); + ss = drop_sessions_in(ss, rs); + sp = remove_space(sp, rs); + // D. a space bound to a repository hides it: the walk keeps it, the sidebar does not + const hp : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, rs)); + if (dk_len(dk, hp) > 0) { + if (msg1(dk[DK_HIDDEN], dk[DK_CONTAINS], hp) == 0) { msg1(dk[DK_HIDDEN], dk[DK_ADD], hp); } + msg2(dk[DK_SLOTS], dk[DK_REPLACE], @as(u64, rs), dk[DK_EMPTY]); + } + auto_open = false; + dnow = true; + if (cur == SESSION_NONE and task != AGENT_QUEEN) { task = CARD_SLOTS; } + if (space == rs) { space = space_at_row(sp, 0); } + } else { + space = rs; + ctl = C_SPACE_ROW; + carg = space; + if (cur != SESSION_NONE and session_space(ss, cur) != space) { cur = SESSION_NONE; task = CARD_SLOTS; } + } + } else if (screen and in_side(mx, ws, collapsed) and row_at(WS_TOP, GROUP_AGENTS, spaces_shown(sp), sessions_in(ss, space), my) != ROW_NONE) { + // W. an agent row selects that chat; N. its x closes it + const r : u32 = row_at(WS_TOP, GROUP_AGENTS, spaces_shown(sp), sessions_in(ss, space), my); + const si : u32 = session_at_row(ss, space, r); + if (on_row_close(mx, my, row_top(WS_TOP, GROUP_AGENTS, spaces_shown(sp), r))) { + if (si == cur) { task = CARD_SLOTS; } + ctl = C_CLOSE_CHAT; + topen = PANE_NONE; + carg = session_agent(ss, si); + ss = remove_session(ss, si); + cur = after_remove(cur, si); + } else { + cur = si; + task = session_agent(ss, si); + ctl = C_AGENT_ROW; + carg = task; + } + } else if (home_live) { + const p : u32 = on_tap(STATE_HOME, to_box(vx, 480, BOX_CX, sc), to_box(vy, 270, BOX_CY, sc)); + if (menu_open(p)) { state = p; shown = p; ctl = C_WORLD; carg = p; } + } + } + if (ty == EV_SCROLL and dp and on_panel(mx, my, sw, WS_TOP)) { + // D. the panel scrolls a row per 24 pt of wheel or trackpad travel + var dy : f64 = msg_real(ev, s_scroll_y); + if (msg0(ev, s_precise) % 256 == 0) { dy = dy * 16.0; } + dacc = dacc + dy; + while (dacc >= 24.0) { + doff = doc_scroll(@as(i64, doff) - 1, @as(u32, @intCast(dk_count(dk, dk[DK_ROWS]))), doc_rows(sh, WS_TOP)); + dacc = dacc - 24.0; + } + while (dacc <= -24.0) { + doff = doc_scroll(@as(i64, doff) + 1, @as(u32, @intCast(dk_count(dk, dk[DK_ROWS]))), doc_rows(sh, WS_TOP)); + dacc = dacc + 24.0; + } + used = true; + } + if (ty == EV_KEY_DOWN) { + const key : u64 = msg0(ev, s_key); + const flags : u64 = msg0(ev, s_flags); + const cmd : bool = has_mod(flags, MOD_COMMAND); + const digit : u32 = digit_of(key); + const kp : u32 = petal_for_key(digit, flags); + used = true; + if (key == KEY_J and cmd) { + // Q. Cmd+J opens or closes the Queen panel anywhere; with GOD MODE on it stops it + const r : u32 = on_queen_key(qp, god); + if (god) { ctl = C_GOD_OFF; } else { ctl = C_QUEEN_KEY; } + qp = r % 2 == 1; + god = r / 2 % 2 == 1; + } else if (key == KEY_ESCAPE and god) { + god = false; + ctl = C_GOD_OFF; + } else if (key == KEY_ESCAPE and dp) { + dp = false; + darmed = ARMED_NONE; + } else if (topen != PANE_NONE and cmd == false and screen) { + // T. the terminal pane takes every key without Cmd, Esc included, as bytes + const ctrl : bool = has_mod(flags, MOD_CONTROL); + var chs : u64 = msg0(ev, s_chars); + if (ctrl) { chs = msg0(ev, s_chars_raw); } + var cp : u32 = 0; + if (chs != 0 and dk_len(dk, chs) > 0) { cp = @as(u32, @intCast(msg_char(chs, s_char_at, 0) & 65535)); } + const kn : u32 = tp_key_bytes(@as(u32, @intCast(key)), cp, ctrl, tkey[0..8]); + if (kn > 0) { + if (tlink == PANE_LIVE) { + if (client_keys(tfd, tpid, tkey[0 .. @as(usize, kn)], tout[0..4100]) == 0) { tlink = PANE_OFFLINE; } + } else { + const en : u32 = tp_echo(tkey[0..8], kn, techo[0..8]); + var ei : u32 = 0; + while (ei < en) { vt_feed(tst[0..10], tcells[0..48000], techo[ei]); ei = ei + 1; } + } + } + tsince = 0; + } else if (key == KEY_ESCAPE) { + state = on_back(state); + } else if (cmd and digit == 0) { + state = on_back(state); + } else if (kp != PETAL_NONE and menu_open(state) == false) { + state = kp; + shown = kp; + } else if (key == KEY_S and cmd and has_mod(flags, MOD_SHIFT)) { + collapsed = collapsed == false; + } else if (key == KEY_F and cmd and has_mod(flags, MOD_CONTROL)) { + msg1(win, s_full, 0); + } else { + used = false; + } + } + // Q. every control the user pressed goes into the ring the Queen reads + if (ctl != C_NONE) { + ring[ring_next(evn)] = ev_pack(view_of(menu_open(state), shown), ctl, carg, now); + evn = evn + 1; + last_user = now; + if (ctl != C_DISMISS) { dismissed = RING_NONE; } + } + if (used == false) { msg1(app, s_send, ev); } + ev = msg_event(app, s_next, 0xffffffffffffffff, 0, mode, true); + } + // D. the walk: a slice of folders per frame; a finished pass forgets what it did not see, then rests + if (walking) { + qhead = dk_walk(dk, qhead, wpass); + if (qhead >= dk_count(dk, dk[DK_QUEUE])) { + if (dk_sweep(dk, wpass) > 0) { darmed = ARMED_NONE; } + walking = false; + qhead = 0; + widle = 0; + dnow = true; + } + } else { + widle = widle + 1; + if (pass_due(widle)) { + wpass = wpass + 1; + dk_start_pass(dk); + walking = true; + } + } + // D. the checks: JOB_SLOTS git / du jobs at a time, each repository in turn, then a rest + var js : u64 = 0; + while (js < @as(u64, JOB_SLOTS)) { + const jt : u64 = dk_at(dk, dk[DK_TASKS], js); + if (jt != dk[DK_NULL]) { + if (msg0(jt, dk[DK_RUNNING]) % 256 == 0) { + dk_take(dk, js, msg0(jt, dk[DK_STATUS]) % 4294967296); + msg2(dk[DK_TASKS], dk[DK_REPLACE], js, dk[DK_NULL]); + dnow = true; + } + } else if (jrest == 0 and dk_count(dk, dk[DK_PATHS]) > 0) { + if (jr >= dk_count(dk, dk[DK_PATHS])) { + jr = 0; + jj = 0; + jpass = jpass + 1; + jrest = RESCAN_FRAMES; + } else { + while (jj < JOB_COUNT and job_due(jj, jpass) == false) { jj = jj + 1; } + if (jj < JOB_COUNT) { + const nt : u64 = dk_launch(dk, js, dk_at(dk, dk[DK_PATHS], jr), jj); + if (nt != 0) { + msg2(dk[DK_TASKS], dk[DK_REPLACE], js, nt); + msg0(nt, dk[DK_RELEASE]); + } + jj = jj + 1; + } + if (jj >= JOB_COUNT) { + jj = 0; + jr = jr + 1; + } + } + } + js = js + 1; + } + if (jrest > 0) { jrest = jrest - 1; } + // D. a deleted clone or worktree vanishes within a lap: one repository's .git checked per frame + const vn : u64 = dk_count(dk, dk[DK_PATHS]); + if (vn > 0) { + if (vr >= vn) { vr = 0; } + if (dk_exists(dk, msg1(dk_at(dk, dk[DK_PATHS], vr), dk[DK_APPEND_PATH], dk[DK_GIT])) == false) { + dk_drop(dk, vr); + darmed = ARMED_NONE; + dnow = true; + } else { + vr = vr + 1; + } + } + // D. the sums, the panel rows and the sidebar slots, every DOC_SUM_EVERY frames or when something moved + if (dnow or frame % DOC_SUM_EVERY == 0) { + dgo = dk_recompute(dk); + var bsl : u32 = 0; + while (bsl < SPACE_COUNT) { + const bound : bool = dk_len(dk, dk_at(dk, dk[DK_SLOTS], @as(u64, bsl))) > 0; + if (space_is_open(sp, bsl) == false and bound) { + msg2(dk[DK_SLOTS], dk[DK_REPLACE], @as(u64, bsl), dk[DK_EMPTY]); + } else if (space_is_open(sp, bsl) and bound == false) { + dk_bind(dk, bsl, RANK_AUTO); + } + bsl = bsl + 1; + } + var more : bool = auto_open; + while (more and spaces_shown(sp) < SPACES_AUTO and new_space(sp) != SPACE_NONE) { + const ns : u32 = new_space(sp); + if (dk_bind(dk, ns, RANK_AUTO)) { sp = add_space(sp, ns); } else { more = false; } + } + dk_names(dk, kit[K_SPACE_NAMES], kit[K_BRANCHES]); + msg2(defs, dk[DK_SET_OBJ], dk[DK_SLOTS], k_slots); + msg2(defs, dk[DK_SET_OBJ], dk[DK_HIDDEN], k_hidden); + dnow = false; + } + if (qp) { dp = false; } + doff = doc_scroll(@as(i64, doff), @as(u32, @intCast(dk_count(dk, dk[DK_ROWS]))), doc_rows(sh, WS_TOP)); + var dlabel : u64 = dk_word(dk, DL_TITLE); + if (dgo > 0) { dlabel = dk_cat(dk, dk_word(dk, DL_FOOT), dk_int(dk, @as(u64, dgo))); } + // T. the terminal pane: a Claude Code chat on screen opens it in its space's folder (home when + // the space is unbound); a new chat or a new size starts the grid over and replays the pane's + // output from the server's ring; an offline pane tries the server again every TP_RETRY frames + if (screen and cur != SESSION_NONE and opens_pane(session_agent(ss, cur))) { + const tox : f64 = content_x(ws, collapsed); + const toy : f64 = WS_TOP + HEADER_H; + const tw : f64 = sw - tox; + const th : f64 = sh - toy; + // a drag changes the size every frame: the pane takes it once it has held TP_SETTLE frames, so + // the grid and the ring replay run once per resize instead of once per frame + tstable = tp_settle(tstable, tp_cols(tw) == twc and tp_rows(th) == twr); + twc = tp_cols(tw); + twr = tp_rows(th); + var tgo : bool = tp_reset_due(topen, cur) or tp_size_due(tst[ST_COLS], tst[ST_ROWS], tw, th, tstable); + if (tlink == PANE_OFFLINE and topen == cur) { + ttry = ttry + 1; + if (tp_retry_due(ttry)) { + ttry = 0; + if (tfd < 0) { tfd = term_connect(thome[0..256]); } + if (tfd >= 0) { tgo = true; } + } + } + if (tgo) { + vt_init(tst[0..10], tcells[0..48000], tp_cols(tw), tp_rows(th)); + tcursor = 0; + var tcwd : u64 = dk_at(dk, dk[DK_SLOTS], @as(u64, session_space(ss, cur))); + if (dk_len(dk, tcwd) == 0) { tcwd = home_dir(); } + if (topen != cur or tlink != PANE_LIVE) { + tpid = PANE_NONE; + if (tfd < 0) { tfd = term_connect(thome[0..256]); } + if (tfd >= 0) { tpid = term_open(tfd, ss, cur, tcwd, tout[0..4100], tdir[0..512]); } + if (tpid != PANE_NONE and client_attach(tfd, tpid, tout[0..4100]) == 0) { tpid = PANE_NONE; } + if (tpid == PANE_NONE) { tlink = PANE_OFFLINE; } else { tlink = PANE_LIVE; } + if (tpid == PANE_NONE and tfd >= 0) { + sys_close(tfd); + tfd = -1; + } + } + // the pty takes the new grid before the replay, so the program redraws at the full width + if (tlink == PANE_LIVE) { client_resize(tfd, tpid, tst[ST_COLS], tst[ST_ROWS], tout[0..4100]); } + if (tlink == PANE_OFFLINE) { term_banner(tst[0..10], tcells[0..48000], tcwd); } + topen = cur; + tsince = 0; + ttry = 0; + tfed = tfed + 1; + } + if (tlink == PANE_LIVE) { + var treads : u32 = 0; + var tmore : bool = true; + while (tmore) { + const rn : usize = client_read(tfd, tpid, tcursor, tout[0..4100]); + if (rn == 0) { + // the server went away: drop the socket; the retry finds it again after a restart + tmore = false; + tlink = PANE_OFFLINE; + sys_close(tfd); + tfd = -1; + } else { + const dn : usize = tp_data_len(tout[0..4100], rn); + if (dn > 0) { + tcursor = reply_cursor(tout[0..4100]); + tfed = tfed + 1; + } + var gi : usize = 0; + while (gi < dn) { vt_feed(tst[0..10], tcells[0..48000], tout[TP_DATA_AT + gi]); gi = gi + 1; } + treads = treads + 1; + tmore = tp_read_more(dn, treads); + } + } + } + } else { + topen = PANE_NONE; + } + if (tsince < 999) { tsince = tsince + 1; } + tcur = tp_cursor_on(frame, tsince); + // Q. GOD MODE: with the panel open and the user still, the Queen runs her suggestion herself + sug = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + if (god_due(god, qp, sug, now, last_user, last_act)) { + const qc : u32 = queen_control(sug); + cur = act_cur(ss, sug, space, cur); + ss = act_sessions(ss, sug, space); + var qa : u32 = 0; + if (cur != SESSION_NONE) { task = session_agent(ss, cur); qa = task; } + ring[ring_next(evn)] = ev_pack(view_of(menu_open(state), shown), qc, qa, now); + evn = evn + 1; + last_act = now; + sug = shown_suggestion(suggest(sessions_in(ss, space), cur != SESSION_NONE), dismissed); + } + ma = wave(ma, menu_open(state)); + glow = glow_level(glow, glow_on(god)); + var hov : u32 = PETAL_NONE; + if (home_live) { hov = hit_px(vx, vy, 960, 540); } + var on_control : bool = on_home_button(mx, my, sw) or on_full_button(mx, my, sw) or on_dock_button(mx, my, sw); + if (screen and (on_nav_back(mx, my, full) or on_side_toggle(mx, my, ws, collapsed))) { on_control = true; } + if (screen and (on_open_dash(mx, my, sw, ws) or on_close_dash(mx, my, ws, collapsed))) { on_control = true; } + if (screen and ma >= 1.0 and card_at(mx, my, sw, sh, ws, collapsed) != CARD_SLOTS) { on_control = true; } + if (screen and ma >= 1.0 and in_side(mx, ws, collapsed) and (row_at(WS_TOP, GROUP_QUEEN, spaces_shown(sp), 1, my) == 0 or row_at(WS_TOP, GROUP_SPACES, spaces_shown(sp), spaces_shown(sp), my) != ROW_NONE or row_at(WS_TOP, GROUP_AGENTS, spaces_shown(sp), sessions_in(ss, space), my) != ROW_NONE)) { on_control = true; } + if (screen and on_new_chat(mx, my, sw, ws)) { on_control = true; } + if (screen and (on_repo_link(mx, my, sh, ws, collapsed) or (in_side(mx, ws, collapsed) and on_space_plus(mx, my) and new_space(sp) != SPACE_NONE))) { on_control = true; } + if (screen and on_doctor_link(mx, my, sh, ws, collapsed)) { on_control = true; } + if (dp and (on_panel_close(mx, my, sw, WS_TOP) or dk_btn_at(dk, mx, my, sw, sh, doff) != NS_NOT_FOUND)) { on_control = true; } + if (qp and (on_panel_close(mx, my, sw, WS_TOP) or on_god(mx, my, sw, WS_TOP) or (sug != A_NONE and (on_confirm(mx, my, sw, WS_TOP) or on_dismiss(mx, my, sw, WS_TOP))))) { on_control = true; } + if (hov != PETAL_NONE or on_control) { msg0(hand, s_cursor_set); } else { msg0(arrow, s_cursor_set); } + // U. the frame gate: fold what this frame shows into a key; an unchanged key with nothing + // animating keeps the image already on the layer (frame_gate.t27) + var fk : u64 = fg_mix(fg_mix(fg_mix(fg_mix(0, @as(u64, state)), @as(u64, hov)), fg_level(ma)), @as(u64, shown)); + fk = fg_mix(fg_mix(fg_mix(fg_mix(fk, @as(u64, task)), @as(u64, space)), @as(u64, sp)), ss); + fk = fg_mix(fg_mix(fg_mix(fg_mix(fk, @as(u64, cur)), fg_flag(collapsed)), fg_mouse(mx, sw)), fg_mouse(my, sh)); + fk = fg_mix(fg_mix(fg_mix(fg_mix(fk, fg_cell(sw)), fg_cell(sh)), @as(u64, ws)), fg_flag(full)); + fk = fg_mix(fg_mix(fg_mix(fk, fg_flag(qp)), @as(u64, evn)), @as(u64, sug)); + // the clock shows only in the queen panel's ages; outside it a ticking second must not count as a change + if (qp) { fk = fg_mix(fk, now); } + fk = fg_mix(fg_mix(fg_mix(fg_mix(fk, fg_flag(god)), fg_level(glow)), fg_flag(dp)), @as(u64, darmed)); + fk = fg_mix(fg_mix(fg_mix(fk, @as(u64, doff)), @as(u64, dgo)), fg_flag(walking)); + fk = fg_mix(fg_mix(fg_mix(fg_mix(fk, @as(u64, topen)), fg_flag(tcur and topen != PANE_NONE)), tfed), fg_flag(narrow)); + fk = fg_mix(fg_mix(fg_mix(fk, @as(u64, tst[ST_X])), @as(u64, tst[ST_Y])), @as(u64, tst[ST_COLS] * 1000 + tst[ST_ROWS])); + // the doctor's table and its label (a new string each frame while checks run) only show in its panel; + // with the panel shut, the background walk does not wake the draw + if (dp) { + fk = fg_mix(fk, dlabel); + var fki : usize = 0; + while (fki < 128) { + fk = fg_mix(fk, dk[fki]); + fki = fki + 1; + } + } + // the home logo flies in for ANIM_FRAMES, the menu waves open and shut, the GOD MODE edge glows + const fanim : bool = frame < ANIM_FRAMES or (ma > 0.0 and ma < 1.0) or glow > 0.0; + const fdraw : bool = fg_draw_due(fk, fshown, fanim, fsince); + fquiet = fg_quiet(fquiet, fk != fshown or fanim); + fsince = fg_since(fsince, fdraw); + fshown = fk; + fwait = fg_wait(fquiet, tlink == PANE_LIVE and topen != PANE_NONE, FRAME_S); + msg_flag(tx_cls, s_quiet, true); + if (fdraw and narrow) { + draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur); + const whole : u64 = cg_image(cc); + const img : u64 = cg_crop(whole, 0.0, 0.0, sw * RETINA, sh * RETINA); + msg1(layer, s_contents, img); + cg_image_release(img); + cg_image_release(whole); + } else if (fdraw) { + draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur); + const whole : u64 = cg_image(c); + const img : u64 = cg_crop(whole, 0.0, 0.0, sw * WIDE_RETINA, sh * WIDE_RETINA); + msg1(layer, s_contents, img); + cg_image_release(img); + cg_image_release(whole); + } + // K. keep what the window shows: write it once a change has held still (window_keep.t27) + const wk_fl : u64 = wk_flags(collapsed, dash, qp, god, dp, auto_open); + const wk_k : u64 = wk_key(state, shown, task, space, ss, sp, cur, wk_fl, evn); + wk_n = wk_still(wk_n, wk_k, wk_last); + wk_last = wk_k; + if (frame == 0) { wk_saved = wk_k; } + if (wk_due(wk_k, wk_saved, wk_n)) { + msg2(defs, sel("setInteger:forKey:"), @as(u64, evn), k_evn); + ri = 0; + while (ri < RING) { + msg2(defs, sel("setInteger:forKey:"), ring[ri], k_ev[ri]); + ri = ri + 1; + } + msg2(defs, sel("setInteger:forKey:"), @as(u64, state), k_state); + msg2(defs, sel("setInteger:forKey:"), @as(u64, shown), k_shown); + msg2(defs, sel("setInteger:forKey:"), @as(u64, task), k_task); + msg2(defs, sel("setInteger:forKey:"), @as(u64, space), k_space); + msg2(defs, sel("setInteger:forKey:"), ss, k_ss); + msg2(defs, sel("setInteger:forKey:"), @as(u64, sp), k_sp); + msg2(defs, sel("setInteger:forKey:"), @as(u64, cur), k_cur); + msg2(defs, sel("setInteger:forKey:"), wk_fl, k_flags); + msg2(defs, sel("setInteger:forKey:"), 1, k_kept); + msg0(defs, sel("synchronize")); + wk_saved = wk_k; + } + // R. a new binary from t27b + ld: wait until it is quiet, save the state, exec it + if (reload_poll(frame)) { + const now : u64 = msg0(msg2(files, s_attrs, exe, 0), s_mdate); + const moved : bool = now == 0 or msg_test1(seen, s_same, now) == false; + if (moved) { + msg0(seen, sel("release")); + seen = now; + msg0(seen, sel("retain")); + } + waited = reload_wait(waited, moved); + if (reload_due(waited) and seen != 0) { + var fl : u64 = 0; + if (collapsed) { fl = fl + 1; } + if (dash) { fl = fl + 2; } + if (qp) { fl = fl + 4; } + if (god) { fl = fl + 8; } + if (dp) { fl = fl + 16; } + if (auto_open == false) { fl = fl + 32; } + msg2(defs, sel("setInteger:forKey:"), @as(u64, evn), k_evn); + ri = 0; + while (ri < RING) { + msg2(defs, sel("setInteger:forKey:"), ring[ri], k_ev[ri]); + ri = ri + 1; + } + msg2(defs, sel("setInteger:forKey:"), @as(u64, state), k_state); + msg2(defs, sel("setInteger:forKey:"), @as(u64, shown), k_shown); + msg2(defs, sel("setInteger:forKey:"), @as(u64, task), k_task); + msg2(defs, sel("setInteger:forKey:"), @as(u64, space), k_space); + msg2(defs, sel("setInteger:forKey:"), ss, k_ss); + msg2(defs, sel("setInteger:forKey:"), @as(u64, sp), k_sp); + msg2(defs, sel("setInteger:forKey:"), @as(u64, cur), k_cur); + msg2(defs, sel("setInteger:forKey:"), fl, k_flags); + msg2(defs, sel("setInteger:forKey:"), 1, k_hot); + msg0(defs, sel("synchronize")); + const path : u64 = msg0(exe, sel("UTF8String")); + var argv : [2]u64 = undefined; + argv[0] = path; + argv[1] = 0; + exec_self(path, argv); + waited = RELOAD_IDLE; + } + } + frame = frame + 1; + pool_pop(pool); + } +} + +// ---- tests ---- + +test the_screen_cross_fades_in_a_quarter_second { + var a : f64 = 0.0; + var i : u32 = 0; + while (i < 7) { a = wave(a, true); i = i + 1; } + assert a < 1.0; + a = wave(a, true); + assert a > 0.999; + // 8 frames at 30 fps = 0.267 s, the nearest frame count to trios' 0.25 s easeInOut + assert @abs(8.0 * FRAME_S - 0.25) < 0.02; + i = 0; + while (i < 8) { a = wave(a, false); i = i + 1; } + assert a < 0.001; +} + +test the_blocks_fly_in_from_800_units_and_settle_home { + assert anim_off(0) == 800.0; + assert anim_off(1) < 800.0; + assert anim_off(40) < 10.0; + assert anim_off(ANIM_FRAMES) == 0.0; +} + +test the_glass_carries_trios_numbers { + assert TINT_BLACK == 0.30; + assert SURFACE_BLACK == 0.46; + assert SIDEBAR_BLACK == 0.22; + assert CONTENT_BLACK == 0.14; + assert BORDER_WHITE == 0.14; + assert SIDE_W == 272.0; + assert HEADER_H == 44.0; + assert WS_TOP == AREA_TOP; + assert READABLE_W == 900.0; + assert chan(outline(), 3) == 1.0; +} + +test every_petal_flies_out_along_a_unit_direction { + var p : u32 = 0; + while (p < PETAL_COUNT) { + const x : f64 = dir(p, 0); + const y : f64 = dir(p, 1); + assert @abs(x * x + y * y - 1.0) < 0.000001; + p = p + 1; + } +} + +test the_title_bar_controls_clear_the_traffic_lights { + // T. macOS draws close / minimise / zoom in x < 76 of the title bar. + assert HOME_X >= 76.0; + // the logo + TRINITY sit in the middle of the bar, clear of the traffic lights and the chevron + assert on_home_button(30.0, 17.0, W_PT) == false; + assert on_home_button(0.5 * W_PT, 17.0, W_PT); + assert on_home_button(0.5 * PANEL_W, 17.0, PANEL_W); + assert brand_left(PANEL_W, HOME_BTN_W) > HOME_X + BACK_W; + assert brand_left(PANEL_W, HOME_BTN_W) + HOME_BTN_W < dock_x(PANEL_W) - DOCK_STEP; + assert brand_left(W_PT, 100.0) + 0.5 * 100.0 == 0.5 * W_PT; + assert home_x(true) < home_x(false); + assert on_full_button(full_x(W_PT) + 10.0, 17.0, W_PT); + assert full_x(W_PT) + FULL_PT <= W_PT - 16.0; + assert on_dock_button(dock_x(W_PT) + 10.0, 17.0, W_PT); + assert on_dock_button(full_x(W_PT) + 10.0, 17.0, W_PT) == false; + assert dock_x(PANEL_W) > HOME_X + HOME_BTN_W; + assert on_nav_back(30.0, 17.0, false) == false; + assert on_nav_back(HOME_X + 4.0, 17.0, false); + assert on_nav_back(30.0, AREA_TOP + 16.0, false) == false; +} + +test the_home_logo_fills_063_of_the_area_and_maps_the_mouse_back { + const z : f64 = 1.0; + const h : f64 = (logo_edge(1, true) - logo_edge(1, false)) / 1000.0 * scale() * home_k(z, W_PT, H_PT); + assert @abs(h - LOGO_SHARE * (H_PT - AREA_TOP)) < 0.001; + // the logo's own middle sits mid-area + const my : f64 = to_pt(0.5 * (logo_edge(1, false) + logo_edge(1, true)), 1, home_c(1, z, W_PT, H_PT), home_k(z, W_PT, H_PT)); + assert @abs(my - 0.5 * (AREA_TOP + H_PT)) < 0.001; + // the logo stays below the title bar + assert to_pt(logo_edge(1, false), 1, home_c(1, z, W_PT, H_PT), home_k(z, W_PT, H_PT)) > AREA_TOP; + // the mouse on the logo centre maps to the centre petal_menu's hit test uses + assert @abs(virt(home_c(0, z, W_PT, H_PT), 0, z, W_PT, H_PT) - 0.5 * W_PT) < 0.001; + assert @abs(virt(home_c(1, z, W_PT, H_PT), 1, z, W_PT, H_PT) - 0.5 * H_PT) < 0.001; +} + +test the_tooltip_floats_30_points_above_its_petal { + const z : f64 = 1.0; + var p : u32 = 0; + while (p < PETAL_COUNT) { + assert @abs(to_pt(cen_y(p), 1, home_c(1, z, W_PT, H_PT), home_k(z, W_PT, H_PT)) - tip_y(p, z, W_PT, H_PT) - TIP_RISE) < 0.001; + p = p + 1; + } +} + +test the_menu_shortcuts_follow_queen_menu_shortcut_policy { + assert digit_of(18) == 1; + assert digit_of(25) == 9; + assert digit_of(29) == 0; + assert digit_of(0) == NO_DIGIT; + assert petal_for_key(1, MOD_COMMAND) == 0; + assert petal_for_key(9, MOD_COMMAND) == 8; + assert petal_for_key(1, MOD_OPTION) == 9; + assert petal_for_key(9, MOD_OPTION) == 17; + assert petal_for_key(1, MOD_CONTROL) == 18; + assert petal_for_key(9, MOD_CONTROL) == 26; + assert petal_for_key(1, MOD_COMMAND + MOD_SHIFT) == PETAL_NONE; + assert petal_for_key(1, MOD_COMMAND + MOD_OPTION) == PETAL_NONE; + assert petal_for_key(0, MOD_COMMAND) == PETAL_NONE; + assert petal_for_key(1, 0) == PETAL_NONE; +} + +test the_sidebar_collapses_and_the_cards_take_the_room { + assert content_x(WS_EXPANDED, false) == SIDE_W + 1.0; + assert content_x(WS_EXPANDED, true) == 0.0; + assert body_w(W_PT, WS_EXPANDED, true) == READABLE_W; + assert body_w(W_PT, WS_EXPANDED, false) < READABLE_W; + assert body_left(W_PT, WS_EXPANDED, false) >= content_x(WS_EXPANDED, false) + BODY_PAD; + assert body_left(W_PT, WS_EXPANDED, true) + body_w(W_PT, WS_EXPANDED, true) <= W_PT - BODY_PAD; + assert on_side_toggle(SIDE_W + 1.0 + 20.0, WS_TOP + 22.0, WS_EXPANDED, false); + assert on_side_toggle(20.0, WS_TOP + 22.0, WS_EXPANDED, true); + assert on_side_toggle(20.0, WS_TOP + 22.0, WS_EXPANDED, false) == false; + const el : f64 = body_left(W_PT, WS_EXPANDED, false); + const ew : f64 = body_w(W_PT, WS_EXPANDED, false); + const eh : f64 = body_h(W_PT, H_PT, WS_EXPANDED, false); + assert card_at(hex_c(0, 0, el, body_top(WS_EXPANDED), ew, eh), hex_c(0, 1, el, body_top(WS_EXPANDED), ew, eh), W_PT, H_PT, WS_EXPANDED, false) == 0; + const cl : f64 = body_left(W_PT, WS_EXPANDED, true); + const cw2 : f64 = body_w(W_PT, WS_EXPANDED, true); + const ch2 : f64 = body_h(W_PT, H_PT, WS_EXPANDED, true); + assert card_at(hex_c(4, 0, cl, body_top(WS_EXPANDED), cw2, ch2), hex_c(4, 1, cl, body_top(WS_EXPANDED), cw2, ch2), W_PT, H_PT, WS_EXPANDED, true) == 4; + assert card_at(10.0, 10.0, W_PT, H_PT, WS_EXPANDED, false) == CARD_SLOTS; +} + +test the_dock_icon_is_a_centred_white_logo_on_black { + const k : f64 = logo_k_for(ICON_LOGO_PX); + const h : f64 = (logo_edge(1, true) - logo_edge(1, false)) / 1000.0 * scale() * k; + assert @abs(h - ICON_LOGO_PX) < 0.001; + // the logo's own middle lands on the icon centre on both axes + const mx : f64 = to_pt(0.5 * (logo_edge(0, false) + logo_edge(0, true)), 0, icon_cen(0), k); + const my : f64 = to_pt(0.5 * (logo_edge(1, false) + logo_edge(1, true)), 1, icon_cen(1), k); + assert @abs(mx - 0.5 * ICON_SIZE) < 0.001; + assert @abs(my - 0.5 * ICON_SIZE) < 0.001; + assert ICON_LOGO_PX < ICON_TILE; +} + +test the_cards_cascade_in_and_all_arrive_at_full_alpha { + assert card_alpha(0.3, 0) > card_alpha(0.3, 8); + var k : u32 = 0; + while (k < CARD_SLOTS) { assert card_alpha(1.0, k) == 1.0; k = k + 1; } +} + +test full_screen_keeps_the_scene_aspect_and_maps_the_mouse_back { + assert fit(960.0, 540.0, W_PT, H_PT) == 1.0; + assert fit(1920.0, 1200.0, W_PT, H_PT) == 2.0; + assert view_to_scene(960.0, 1920.0, 1200.0, 0, W_PT, H_PT) == 480.0; + assert view_to_scene(600.0, 1920.0, 1200.0, 1, W_PT, H_PT) == 270.0; + assert view_to_scene(0.0, 960.0, 540.0, 1, W_PT, H_PT) == 540.0; +} + +test full_screen_draws_the_home_logo_20_percent_smaller_and_still_hits { + assert @abs(home_px(FULL_LOGO, W_PT, H_PT) - 0.8 * home_px(1.0, W_PT, H_PT)) < 0.000001; + assert @abs(home_k(FULL_LOGO, W_PT, H_PT) - 0.8 * home_k(1.0, W_PT, H_PT)) < 0.000001; + assert has_mod(MASK_FULL_SCREEN + STYLE, MASK_FULL_SCREEN); + assert has_mod(STYLE, MASK_FULL_SCREEN) == false; + // the smaller logo stays centred, and the mouse on its centre still maps to the hit-test centre + assert @abs(virt(home_c(0, FULL_LOGO, W_PT, H_PT), 0, FULL_LOGO, W_PT, H_PT) - 0.5 * W_PT) < 0.001; + assert @abs(virt(home_c(1, FULL_LOGO, W_PT, H_PT), 1, FULL_LOGO, W_PT, H_PT) - 0.5 * H_PT) < 0.001; +} + +test the_window_folds_into_the_trios_compact_panel_below_760 { + // K. ChatWorkspaceLayout: expanded from 760, compact below, the forced dashboard on request. + assert workspace(759.0, false) == WS_COMPACT; + assert workspace(760.0, false) == WS_EXPANDED; + assert workspace(760.0, true) == WS_EXPANDED; + assert workspace(PANEL_W, true) == WS_DASHBOARD; + assert side_w(WS_COMPACT, false) == 0.0; + assert side_w(WS_DASHBOARD, false) == 0.0; + assert side_w(WS_EXPANDED, false) == SIDE_W; + // the OPEN DASHBOARD bar spans the panel, and only in compact + assert on_open_dash(200.0, WS_TOP + 10.0, PANEL_W, WS_COMPACT); + assert on_open_dash(200.0, WS_TOP + 10.0, PANEL_W, WS_DASHBOARD) == false; + assert on_open_dash(200.0, WS_TOP + BAR_H + 5.0, PANEL_W, WS_COMPACT) == false; + // Close Dashboard sits right of the toggle, only in the forced dashboard + assert on_close_dash(close_x(WS_DASHBOARD, false) + 10.0, WS_TOP + 22.0, WS_DASHBOARD, false); + assert on_close_dash(close_x(WS_EXPANDED, false) + 10.0, WS_TOP + 22.0, WS_EXPANDED, false) == false; + assert on_side_toggle(20.0, WS_TOP + 22.0, WS_COMPACT, false) == false; + assert on_side_toggle(20.0, WS_TOP + 22.0, WS_DASHBOARD, false); + // the narrow scene is the view point for point; the cards take the panel under the bar + assert scene(PANEL_W, 900.0, 0) == PANEL_W; + assert scene(PANEL_W, 900.0, 1) == 900.0; + assert scene(PANEL_W, 2000.0, 1) == CH_MAX; + assert fit(PANEL_W, 900.0, PANEL_W, 900.0) == 1.0; + assert body_w(PANEL_W, WS_COMPACT, false) == PANEL_W - 2.0 * BODY_PAD; + const kl : f64 = body_left(PANEL_W, WS_COMPACT, false); + const kw : f64 = body_w(PANEL_W, WS_COMPACT, false); + const kh : f64 = body_h(PANEL_W, 900.0, WS_COMPACT, false); + assert card_at(hex_c(4, 0, kl, body_top(WS_COMPACT), kw, kh), hex_c(4, 1, kl, body_top(WS_COMPACT), kw, kh), PANEL_W, 900.0, WS_COMPACT, false) == 4; + // the slots keep their wide shape instead of stretching down the panel + assert @abs(body_h(PANEL_W, 900.0, WS_COMPACT, false) / body_w(PANEL_W, WS_COMPACT, false) - wide_shape()) < 0.000001; + assert body_h(PANEL_W, 900.0, WS_COMPACT, false) < 900.0 - body_top(WS_COMPACT) - BODY_PAD; + // the home logo fits inside the 400 pt panel and still maps back to the hit-test centre + const k : f64 = home_k(1.0, PANEL_W, 900.0); + assert to_pt(logo_edge(0, false), 0, home_c(0, 1.0, PANEL_W, 900.0), k) > 0.0; + assert to_pt(logo_edge(0, true), 0, home_c(0, 1.0, PANEL_W, 900.0), k) < PANEL_W; + assert @abs(virt(home_c(0, 1.0, PANEL_W, 900.0), 0, 1.0, PANEL_W, 900.0) - 0.5 * W_PT) < 0.001; + assert @abs(virt(home_c(1, 1.0, PANEL_W, 900.0), 1, 1.0, PANEL_W, 900.0) - 0.5 * H_PT) < 0.001; + // the compact bitmap holds the widest and tallest narrow scene + assert CW_MAX * RETINA <= 3040.0; + assert CH_MAX * RETINA <= 5200.0; +} + +test full_screen_draws_point_for_point_on_black_glass { + // F. a 1728 x 1117 pt full screen is its own scene: no 960 x 540 upscale, no letterbox bands + assert scene(1728.0, 1117.0, 0) == 1728.0; + assert scene(1728.0, 1117.0, 1) == 1117.0; + assert fit(1728.0, 1117.0, scene(1728.0, 1117.0, 0), scene(1728.0, 1117.0, 1)) == 1.0; + assert scene(W_PT, H_PT, 0) == W_PT; + assert scene(3000.0, 2000.0, 0) == SW_MAX; + assert scene(3000.0, 2000.0, 1) == SH_MAX; + assert SW_MAX * WIDE_RETINA <= 3840.0; + assert SH_MAX * WIDE_RETINA <= 2400.0; + // the hairline is one device pixel at the display's own scale + assert HAIR * WIDE_RETINA == 1.0; + assert TINT_FULL > TINT_BLACK; + assert TINT_FULL < 1.0; + // F. the background is black glass: R = G = B = 0, translucent in a window, opaque in full screen + assert BACK_BLACK == 1.0; + assert CROWN_WHITE == 0.70; + assert STRICT_CARD_EDGE > STRICT_CARD_FILL; + // the cards still tile the readable column in full screen + const fl : f64 = body_left(1728.0, WS_EXPANDED, false); + const fw : f64 = body_w(1728.0, WS_EXPANDED, false); + const fh : f64 = body_h(1728.0, 1117.0, WS_EXPANDED, false); + assert card_at(hex_c(0, 0, fl, body_top(WS_EXPANDED), fw, fh), hex_c(0, 1, fl, body_top(WS_EXPANDED), fw, fh), 1728.0, 1117.0, WS_EXPANDED, false) == 0; +} + +test logo_is_the_brand_mark_and_title_says_trinity_once { + // H. white petals with no outline, the centre left open, as the Trinity S3AI mark + assert LOGO_WHITE > 0.9; + assert LOGO_WHITE <= 1.0; + // S. one bar: the back chevron sits in the title bar right before the logo + assert on_nav_back(FULL_HOME_X + 2.0, 0.5 * TITLE_H, true); + assert on_nav_back(FULL_HOME_X + 2.0, AREA_TOP + 10.0, true) == false; + // L. layout.t27: chevron, logo and TRINITY on one centre line, equal gaps, equal icon heights + assert NAV_SHIFT == row_next(0.0, CHEV_W, ROW_GAP); + assert same_line(cap_mid(baseline_for(0.5 * TITLE_H, 11.0), 11.0), 0.5 * TITLE_H); + assert title_logo_w() > ROW_ICON_H; + assert title_logo_w() < 2.0 * ROW_ICON_H; + assert TITLE_LOGO_SCALE == 1.2; +} + +test the_comb_holds_the_agents_with_the_queen_in_the_middle { + // A. the Queen is the middle cell of the middle row + assert AGENT_QUEEN / 3 == 1; + assert AGENT_QUEEN % 3 == 1; + // the mark and the name stay inside the cell + assert AGENT_MARK_UP + AGENT_MARK < 1.0; + assert AGENT_NAME_DOWN < 0.5; + assert agent_name_pt(200.0) == AGENT_NAME_PT; + assert agent_name_pt(40.0) < AGENT_NAME_MIN; +} + +test cards_are_a_honeycomb_and_the_logo_has_two_sizes { + // C. a 600 x 500 body: the comb fits both ways, cells do not touch, the middle row sits half a cell right + const r : f64 = hex_r(600.0, 500.0); + assert 3.5 * SQRT3 * r + 2.5 * HEX_GAP <= 600.001; + assert 5.0 * r + 2.0 * HEX_GAP <= 500.001; + assert in_hex(hex_c(0, 0, 0.0, 0.0, 600.0, 500.0), hex_c(0, 1, 0.0, 0.0, 600.0, 500.0), hex_c(0, 0, 0.0, 0.0, 600.0, 500.0), hex_c(0, 1, 0.0, 0.0, 600.0, 500.0), r); + assert hex_c(1, 0, 0.0, 0.0, 600.0, 500.0) - hex_c(0, 0, 0.0, 0.0, 600.0, 500.0) > SQRT3 * r; + assert @abs(hex_c(3, 0, 0.0, 0.0, 600.0, 500.0) - hex_c(0, 0, 0.0, 0.0, 600.0, 500.0) - 0.5 * (SQRT3 * r + HEX_GAP)) < 0.001; + assert @abs(hex_c(6, 0, 0.0, 0.0, 600.0, 500.0) - hex_c(0, 0, 0.0, 0.0, 600.0, 500.0)) < 0.001; + // a corner of the bounding box is outside the hexagon + assert in_hex(0.0, 0.0, 0.5 * SQRT3 * r, r, r) == false; + // H. the large mark is parted by a black cut; the small one by a finer one + assert LOGO_SEAL > 0.0; + assert LOGO_SEAL < LOGO_CUT; +} + +test small_mark_keeps_the_centre_hole { + // H. the hole is the mark's inner equilateral triangle: height = width * sqrt(3) / 2 within 1 percent + const wd : f64 = HOLE_R - HOLE_L; + assert @abs((HOLE_AY - HOLE_TOP) - 0.5 * SQRT3 * wd) < 0.01 * wd; + assert @abs(HOLE_AX - 0.5 * (HOLE_L + HOLE_R)) < 1000.0; + // it sits inside the logo box + assert HOLE_L > logo_edge(0, false); + assert HOLE_R < logo_edge(0, true); + assert HOLE_TOP > logo_edge(1, false); + assert HOLE_AY < logo_edge(1, true); + assert HOLE_GROW > 1.0; +} + +test the_sidebar_rows_answer_only_where_the_sidebar_is { + assert in_side(20.0, WS_EXPANDED, false); + assert in_side(20.0, WS_EXPANDED, true) == false; + assert in_side(20.0, WS_COMPACT, false) == false; + assert in_side(SIDE_W - 6.0, WS_EXPANDED, false) == false; + // the Queen row stays where it was before the groups: label at +22, row at +30 + assert label_base(WS_TOP, GROUP_QUEEN, spaces_shown(SPACES_SEED)) == WS_TOP + 22.0; + assert row_top(WS_TOP, GROUP_QUEEN, spaces_shown(SPACES_SEED), 0) == WS_TOP + 30.0; + // with the three seed spaces, five chats fit above the foot strip of the 540-pt window; the sixth runs + // under OPEN SOURCE T27 (scrolling is not built yet). The old claim, seven, ended at 547 pt. + assert row_top(WS_TOP, GROUP_AGENTS, spaces_shown(SPACES_SEED), 4) + AGENT_ROW_H <= H_PT - FOOT_H; + assert row_top(WS_TOP, GROUP_AGENTS, spaces_shown(SPACES_SEED), 5) + AGENT_ROW_H > H_PT - FOOT_H; + // the foot link answers only inside the sidebar strip + assert on_repo_link(40.0, H_PT - 20.0, H_PT, WS_EXPANDED, false); + assert on_repo_link(40.0, H_PT - 20.0, H_PT, WS_EXPANDED, true) == false; + assert on_repo_link(40.0, H_PT - FOOT_H - 2.0, H_PT, WS_EXPANDED, false) == false; + assert on_doctor_link(SIDE_W - 40.0, H_PT - 20.0, H_PT, WS_EXPANDED, false); + assert on_repo_link(SIDE_W - 40.0, H_PT - 20.0, H_PT, WS_EXPANDED, false) == false; + assert on_doctor_link(40.0, H_PT - 20.0, H_PT, WS_EXPANDED, false) == false; + // the SPACES "+" and a space row x sit at the right end, clear of the row text + assert on_space_plus(SIDE_W - 26.0, label_base(WS_TOP, GROUP_SPACES, 3) - 3.5); + assert on_space_plus(40.0, label_base(WS_TOP, GROUP_SPACES, 3) - 3.5) == false; + const st : f64 = row_top(WS_TOP, GROUP_SPACES, 3, 0); + assert on_space_close(SIDE_W - 26.0, st + 14.0, st); + assert on_space_close(SIDE_W - 26.0, st + 30.0, st) == false; +} + +test the_brand_is_trinity_s_superscript_three_ai_in_utf8 { + var b : [16]u8 = undefined; + assert brand(b[0 .. 16]) == BRAND_LEN; + assert b[0] == 84; + assert b[8] == 83; + // U+00B3 is C2 B3 in UTF-8, written as bytes because a t27b literal does not decode \x + assert b[9] == 0xC2; + assert b[10] == 0xB3; + assert b[11] == 65; + assert b[12] == 73; +} + +test every_comb_cell_gets_the_same_outline { + var k : u32 = 0; + while (k < CARD_SLOTS) { + assert cell_line(k) == HAIR; + // no hover and no task: every cell has the plain edge, cell 0 included + assert cell_edge(k, CARD_SLOTS, CARD_SLOTS) == STRICT_CARD_EDGE; + // hover and selection look the same on every cell + assert cell_edge(k, k, CARD_SLOTS) == STRICT_HOVER_EDGE; + assert cell_edge(k, CARD_SLOTS, k) == TASK_EDGE; + k = k + 1; + } +} + +test the_new_chat_plus_and_the_row_close_answer_only_on_their_boxes { + // N. "+" sits at the right end of the header, inside the content, never in the compact bar + const sw : f64 = 1200.0; + const ty : f64 = WS_TOP + 0.5 * HEADER_H; + assert on_new_chat(new_chat_x(sw) + 0.5 * TOGGLE_PT, ty, sw, WS_EXPANDED); + assert on_new_chat(new_chat_x(sw) + 0.5 * TOGGLE_PT, ty, sw, WS_COMPACT) == false; + assert on_new_chat(new_chat_x(sw) - 4.0, ty, sw, WS_EXPANDED) == false; + assert new_chat_x(sw) + TOGGLE_PT < sw; + // it does not overlap the sidebar toggle + assert on_side_toggle(new_chat_x(sw) + 0.5 * TOGGLE_PT, ty, WS_EXPANDED, false) == false; + // N. the close x of a row is where its dot is drawn, and only there + const top : f64 = 200.0; + assert on_row_close(SIDE_W - 26.0, top + 19.0, top); + assert on_row_close(40.0, top + 19.0, top) == false; + assert on_row_close(SIDE_W - 26.0, top + 40.0, top) == false; + assert in_side(SIDE_W - 26.0, WS_EXPANDED, false); +} diff --git a/specs/t27os/hot_reload.t27 b/specs/t27os/hot_reload.t27 new file mode 100644 index 0000000000..c29400cf6b --- /dev/null +++ b/specs/t27os/hot_reload.t27 @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/hot_reload.t27 -- hot reload for the T27 OS glass window, built by t27b. +; Owner, 2026-10-19: use t27b for hot-reload development. +; t27b builds the window to a Mach-O object on the lab; ld links it into T27OS.app on the Mac. The +; running window watches its own executable: every RELOAD_POLL frames it reads the file's modification +; date. When the date moves (ld wrote a new binary), it waits until RELOAD_SETTLE checks in a row see +; no further change (codesign rewrites the file once more right after ld), then saves what the user is +; looking at -- the open screen, the open task, the sidebar, the dashboard -- under HOT_KEY in the user +; defaults and execs itself. The new process finds HOT_KEY set, restores that state with no fade-in, +; and clears the key, so an ordinary launch still starts at home. No pkill, no relaunch by hand. +; Why exec and not a swapped dylib: t27b has no call through a function pointer yet, so a draw +; function cannot be looked up and called from a reloaded library. The window flickers once per swap. + +module T27osHotReload; + +/// Frames between two looks at the executable (0.5 s at 30 fps). +pub const RELOAD_POLL : u32 = 15; +/// Quiet checks in a row before the swap: ld and then codesign each write the file. +pub const RELOAD_SETTLE : u32 = 2; +/// No change seen since the last swap. +pub const RELOAD_IDLE : u32 = 4294967295; + +/// The quiet-check count after one look: back to 0 when the file moved, unchanged when idle. +pub fn reload_wait(w: u32, moved: bool) -> u32 { + if (moved) { return 0; } + if (w == RELOAD_IDLE) { return w; } + return w + 1; +} + +/// True when the new binary has been quiet long enough to exec it. +pub fn reload_due(w: u32) -> bool { + return w != RELOAD_IDLE and w >= RELOAD_SETTLE; +} + +/// True on the frames that look at the executable. +pub fn reload_poll(frame: u32) -> bool { + return frame % RELOAD_POLL == 0; +} + +test a_new_binary_is_swapped_in_once_ld_and_codesign_are_done { + var w : u32 = RELOAD_IDLE; + w = reload_wait(w, false); + assert reload_due(w) == false; + // ld writes the binary + w = reload_wait(w, true); + assert reload_due(w) == false; + // codesign writes it again before the window acts + w = reload_wait(w, false); + w = reload_wait(w, true); + assert reload_due(w) == false; + w = reload_wait(w, false); + assert reload_due(w) == false; + w = reload_wait(w, false); + assert reload_due(w); +} + +test the_window_looks_twice_a_second { + assert reload_poll(0); + assert reload_poll(RELOAD_POLL); + assert reload_poll(1) == false; + assert RELOAD_POLL * RELOAD_SETTLE == 30; +} diff --git a/specs/t27os/layout.t27 b/specs/t27os/layout.t27 new file mode 100644 index 0000000000..919aa4b8ca --- /dev/null +++ b/specs/t27os/layout.t27 @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/layout.t27 -- a small layout helper for T27 OS screens, so placement is computed and +; tested instead of tuned by eye. +; Owner, 2026-10-19: build a tool in .t27 that helps with layout. +; A row is a run of items on one centre line `mid`: icons and text. The helper answers the +; questions a screen keeps asking by hand: +; where does the next item start (row_next: previous left + width + gap), +; where is the top of an item of height h centred on the line (centre_top), +; which text baseline puts the capitals of a font size on the line (baseline_for, cap_mid), +; is a value on the device pixel grid (snap), and do two items share the line (same_line). +; Icons in one row share one height (ROW_ICON_H) so their top and bottom edges match; gaps in one +; row are equal (ROW_GAP). glass_window.t27 places its title bar with these functions. + +module T27osLayout; + +/// Capital height of the system UI font as a share of the point size (SF Pro / SF Mono, 0.70). +pub const CAP_SHARE : f64 = 0.70; +/// Icons in a title-bar row: one height, so the chevron and the logo start and end on the same lines. +pub const ROW_ICON_H : f64 = 11.0; +/// The gap between neighbours in a title-bar row. +pub const ROW_GAP : f64 = 8.0; +/// Two centre lines closer than this are the same line. +pub const LINE_EPS : f64 = 0.01; + +/// Left edge of the item after one that starts at x and is w wide, gap apart. +pub fn row_next(x: f64, w: f64, gap: f64) -> f64 { + return x + w + gap; +} + +/// Top of an item of height h centred on the line mid. +pub fn centre_top(mid: f64, h: f64) -> f64 { + return mid - 0.5 * h; +} + +/// The centre of the capitals of a size-pt line drawn at baseline. +pub fn cap_mid(baseline: f64, size: f64) -> f64 { + return baseline - 0.5 * CAP_SHARE * size; +} + +/// The baseline that puts the capitals of a size-pt line centred on mid. +pub fn baseline_for(mid: f64, size: f64) -> f64 { + return mid + 0.5 * CAP_SHARE * size; +} + +/// v rounded to the nearest device pixel at the given backing scale (v >= 0). +pub fn snap(v: f64, scale: f64) -> f64 { + const px : i64 = @as(i64, @intFromFloat(v * scale + 0.5)); + return @as(f64, @floatFromInt(px)) / scale; +} + +/// True when two centre lines are the same line. +pub fn same_line(a: f64, b: f64) -> bool { + return @abs(a - b) < LINE_EPS; +} + +test a_row_steps_by_width_plus_gap { + assert row_next(84.0, 5.0, ROW_GAP) == 97.0; + assert row_next(row_next(84.0, 5.0, ROW_GAP), 13.0, ROW_GAP) == 118.0; +} + +test icons_and_capitals_share_the_centre_line { + const mid : f64 = 17.0; + assert centre_top(mid, ROW_ICON_H) == 11.5; + assert centre_top(mid, ROW_ICON_H) + ROW_ICON_H == 22.5; + assert same_line(cap_mid(baseline_for(mid, 11.0), 11.0), mid); + assert same_line(cap_mid(baseline_for(mid, 13.0), 13.0), mid); + assert same_line(mid, mid + 0.5) == false; +} + +test snap_lands_on_the_pixel_grid { + assert snap(13.3, 2.0) == 13.5; + assert snap(13.2, 2.0) == 13.0; + assert snap(17.0, 2.0) == 17.0; +} + +test gaps_are_positive { + assert ROW_GAP > 0.0; + assert ROW_ICON_H > 0.0; + assert CAP_SHARE > 0.5 and CAP_SHARE < 0.8; +} diff --git a/specs/t27os/pane_state.t27 b/specs/t27os/pane_state.t27 new file mode 100644 index 0000000000..1834e3afad --- /dev/null +++ b/specs/t27os/pane_state.t27 @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/pane_state.t27 -- what each pane of the PTY server is doing: working, blocked, done or idle. +; Owner, 2026-10-19: the herdr model. herdr marks every pane working, blocked (waiting on the human) or +; idle, so the one that needs you is the one you look at. Its signals, which this spec follows: +; 1. the agent's own hooks come first -- Claude Code reports UserPromptSubmit (a turn starts), Notification +; (it asks for permission or input) and Stop (the turn ended). A hook is exact, so a pane that has sent +; one is never guessed at again from its output; +; 2. output quietness is the fallback for a CLI without hooks: output means working, a quiet stretch +; means idle. +; The status of a pane is one u32: bits 0..7 the raw state (agents.t27 S_*), bit 8 "unseen" (a turn ended +; and nobody has looked), bit 9 "hooked" (a hook was seen on this pane). The manifest stores the raw state; +; the sidebar shows shown_state(), which is DONE only for an idle pane with an unseen finished turn. +; Why unseen is a bit and not the state S_DONE: agents.t27 makes S_DONE the state of a process that +; exited, and restore skips it. A Claude pane that finished a turn is still alive and must come back. +; The server calls pane_step on every event and nothing else: no decision lives in a host loop. + +module T27osPaneState; + +use t27os::agents::S_STOPPED; +use t27os::agents::S_WORKING; +use t27os::agents::S_BLOCKED; +use t27os::agents::S_IDLE; +use t27os::agents::S_DONE; + +/// Milliseconds without output after which an un-hooked pane counts as idle. +pub const QUIET_MS : u64 = 2500; + +/// Events a pane reports. +pub const P_HOOK_START : u32 = 0; +pub const P_HOOK_SUBMIT : u32 = 1; +pub const P_HOOK_NOTIFY : u32 = 2; +pub const P_HOOK_STOP : u32 = 3; +pub const P_OUTPUT : u32 = 4; +pub const P_QUIET : u32 = 5; +pub const P_KEY : u32 = 6; +pub const P_SEEN : u32 = 7; +pub const P_EXIT_OK : u32 = 8; +pub const P_EXIT_FAIL : u32 = 9; +pub const P_SPAWNED : u32 = 10; +pub const P_COUNT : u32 = 11; + +pub const BIT_UNSEEN : u32 = 256; +pub const BIT_HOOKED : u32 = 512; + +pub fn ps_state(w: u32) -> u32 { return w & 255; } +pub fn ps_unseen(w: u32) -> bool { return (w & BIT_UNSEEN) != 0; } +pub fn ps_hooked(w: u32) -> bool { return (w & BIT_HOOKED) != 0; } + +pub fn ps_make(state: u32, unseen: bool, hooked: bool) -> u32 { + var w : u32 = state & 255; + if (unseen) { w = w + BIT_UNSEEN; } + if (hooked) { w = w + BIT_HOOKED; } + return w; +} + +/// The next status of a pane with status w on event e. +pub fn pane_step(w: u32, e: u32) -> u32 { + const s : u32 = ps_state(w); + const u : bool = ps_unseen(w); + const h : bool = ps_hooked(w); + if (e == P_SPAWNED) { return ps_make(S_IDLE, false, false); } + if (e == P_EXIT_OK) { return ps_make(S_DONE, false, h); } + if (e == P_EXIT_FAIL) { return ps_make(S_STOPPED, false, h); } + if (s == S_STOPPED or s == S_DONE) { return w; } + if (e == P_HOOK_START) { return ps_make(S_IDLE, false, true); } + if (e == P_HOOK_SUBMIT) { return ps_make(S_WORKING, false, true); } + if (e == P_HOOK_NOTIFY) { return ps_make(S_BLOCKED, u, true); } + if (e == P_HOOK_STOP) { return ps_make(S_IDLE, true, true); } + if (e == P_KEY) { + // answering a permission prompt starts work again, and no hook says so + if (s == S_BLOCKED) { return ps_make(S_WORKING, u, h); } + return w; + } + if (e == P_SEEN) { return ps_make(s, false, h); } + if (h) { return w; } + if (e == P_OUTPUT) { return ps_make(S_WORKING, u, h); } + if (e == P_QUIET and s == S_WORKING) { return ps_make(S_IDLE, u, h); } + return w; +} + +/// What the sidebar shows: DONE for an idle pane whose finished turn nobody has seen. +pub fn shown_state(w: u32) -> u32 { + const s : u32 = ps_state(w); + if (s == S_IDLE and ps_unseen(w)) { return S_DONE; } + return s; +} + +/// True when the pane needs the human: blocked. +pub fn pane_needs_you(w: u32) -> bool { + return ps_state(w) == S_BLOCKED; +} + +/// True when the output-quiet fallback applies to a pane: a pane that sent a hook is never guessed at. +pub fn quiet_applies(w: u32) -> bool { + return !ps_hooked(w) and ps_state(w) == S_WORKING; +} + +/// True when a pane that last wrote at last_ms has been quiet at now_ms. +pub fn is_quiet(now_ms: u64, last_ms: u64) -> bool { + if (now_ms < last_ms) { return false; } + return now_ms - last_ms >= QUIET_MS; +} + +/// The event a Claude Code hook name stands for; P_COUNT when the name is not one we read. +/// Names: SessionStart, UserPromptSubmit, Notification, Stop. +pub fn hook_event(name: []const u8) -> u32 { + if (name.len == 4 and name[0] == 83 and name[1] == 116 and name[2] == 111 and name[3] == 112) { + return P_HOOK_STOP; + } + if (name.len == 12 and name[0] == 78 and name[1] == 111 and name[2] == 116 and name[3] == 105) { + return P_HOOK_NOTIFY; + } + if (name.len == 16 and name[0] == 85 and name[1] == 115 and name[2] == 101 and name[3] == 114) { + return P_HOOK_SUBMIT; + } + if (name.len == 12 and name[0] == 83 and name[1] == 101 and name[2] == 115 and name[3] == 115) { + return P_HOOK_START; + } + return P_COUNT; +} + +test a_claude_turn_walks_the_four_states { + var w : u32 = pane_step(0, P_SPAWNED); + assert shown_state(w) == S_IDLE; + w = pane_step(w, P_HOOK_START); + assert ps_hooked(w); + w = pane_step(w, P_HOOK_SUBMIT); + assert shown_state(w) == S_WORKING; + w = pane_step(w, P_HOOK_NOTIFY); + assert shown_state(w) == S_BLOCKED; + assert pane_needs_you(w); + w = pane_step(w, P_KEY); + assert shown_state(w) == S_WORKING; + assert pane_needs_you(w) == false; + w = pane_step(w, P_HOOK_STOP); + assert ps_state(w) == S_IDLE; + assert shown_state(w) == S_DONE; + w = pane_step(w, P_SEEN); + assert shown_state(w) == S_IDLE; +} + +test a_hooked_pane_ignores_output_and_quiet { + var w : u32 = pane_step(pane_step(0, P_SPAWNED), P_HOOK_SUBMIT); + var i : u32 = 0; + while (i < 5) { + w = pane_step(w, P_OUTPUT); + w = pane_step(w, P_QUIET); + i = i + 1; + } + assert shown_state(w) == S_WORKING; + assert quiet_applies(w) == false; + w = pane_step(w, P_HOOK_STOP); + w = pane_step(w, P_OUTPUT); + assert ps_state(w) == S_IDLE; +} + +test a_pane_without_hooks_falls_back_to_quietness { + var w : u32 = pane_step(0, P_SPAWNED); + w = pane_step(w, P_OUTPUT); + assert shown_state(w) == S_WORKING; + assert quiet_applies(w); + w = pane_step(w, P_QUIET); + assert shown_state(w) == S_IDLE; + w = pane_step(w, P_QUIET); + assert shown_state(w) == S_IDLE; + w = pane_step(w, P_OUTPUT); + assert shown_state(w) == S_WORKING; +} + +test exits_are_final { + var w : u32 = pane_step(pane_step(0, P_SPAWNED), P_OUTPUT); + const done : u32 = pane_step(w, P_EXIT_OK); + assert ps_state(done) == S_DONE; + assert pane_step(done, P_OUTPUT) == done; + assert pane_step(done, P_HOOK_SUBMIT) == done; + const dead : u32 = pane_step(w, P_EXIT_FAIL); + assert ps_state(dead) == S_STOPPED; + assert pane_step(dead, P_KEY) == dead; + // a respawn starts a fresh pane + assert ps_state(pane_step(dead, P_SPAWNED)) == S_IDLE; +} + +test unseen_survives_a_prompt_and_clears_on_looking { + var w : u32 = pane_step(pane_step(0, P_SPAWNED), P_HOOK_SUBMIT); + w = pane_step(w, P_HOOK_STOP); + assert ps_unseen(w); + w = pane_step(w, P_HOOK_NOTIFY); + assert ps_unseen(w); + w = pane_step(w, P_SEEN); + assert ps_unseen(w) == false; + assert shown_state(w) == S_BLOCKED; + w = pane_step(w, P_HOOK_SUBMIT); + assert ps_unseen(w) == false; +} + +test quiet_is_measured_in_milliseconds { + var now : u64 = 1000; + assert is_quiet(now, 1000) == false; + now = 1000 + QUIET_MS - 1; + assert is_quiet(now, 1000) == false; + now = now + 1; + assert is_quiet(now, 1000); + now = 500; + assert is_quiet(now, 1000) == false; +} + +test hook_names_map_to_events { + var n : [16]u8 = undefined; + n[0] = 83; n[1] = 116; n[2] = 111; n[3] = 112; + assert hook_event(n[0..4]) == P_HOOK_STOP; + n[0] = 78; n[1] = 111; n[2] = 116; n[3] = 105; + n[4] = 102; n[5] = 105; n[6] = 99; n[7] = 97; n[8] = 116; n[9] = 105; n[10] = 111; n[11] = 110; + assert hook_event(n[0..12]) == P_HOOK_NOTIFY; + assert hook_event(n[0..11]) == P_COUNT; + n[0] = 85; n[1] = 115; n[2] = 101; n[3] = 114; + assert hook_event(n[0..16]) == P_HOOK_SUBMIT; + n[0] = 83; n[1] = 101; n[2] = 115; n[3] = 115; + assert hook_event(n[0..12]) == P_HOOK_START; + n[0] = 88; + assert hook_event(n[0..12]) == P_COUNT; +} diff --git a/specs/t27os/pty_client.t27 b/specs/t27os/pty_client.t27 new file mode 100644 index 0000000000..2acfb1d48b --- /dev/null +++ b/specs/t27os/pty_client.t27 @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS pty_client: what a window calls to talk to the PTY server (pty_server.t27) over its Unix socket: +// connect, then one request frame out and one reply frame back. The wire format is pty_proto.t27. +// +// The calls are blocking and stateless. A window that wants a live pane polls OP_READ with the cursor it +// was last given; the server never pushes. Closing the socket (or the whole process dying) detaches +// nothing and kills nothing: the panes belong to the server. +// +// Refs gHashTag/t27#10782. +module T27osPtyClient; + +use t27os::pty_proto::OP_LIST; +use t27os::pty_proto::OP_SPAWN; +use t27os::pty_proto::OP_ATTACH; +use t27os::pty_proto::OP_DETACH; +use t27os::pty_proto::OP_KEYS; +use t27os::pty_proto::OP_READ; +use t27os::pty_proto::OP_KILL; +use t27os::pty_proto::OP_RESIZE; +use t27os::pty_proto::put_u16; +use t27os::pty_proto::HEADER; +use t27os::pty_proto::MAX_FRAME; +use t27os::pty_proto::put_u64; +use t27os::pty_proto::get_u64; +use t27os::pty_proto::put_frame; +use t27os::pty_proto::frame_need; +use t27os::pty_proto::socket_path; +use t27os::pty_sys::sys_write; +use t27os::pty_sys::sys_read; +use t27os::pty_sys::sys_close; +use t27os::pty_sys::sys_socket; +use t27os::pty_sys::sys_connect; + +pub const AF_UNIX : i32 = 1; +pub const SOCK_STREAM : i32 = 1; +pub const SOCKADDR_LEN : u32 = 106; + +/// The sockaddr_un of a socket path: sun_len, AF_UNIX, then the NUL-terminated path in 104 bytes. +pub fn sockaddr_un(out: []u8, path: []const u8) -> usize { + var i : usize = 0; + while (i < 106) { + out[i] = 0; + i = i + 1; + } + out[0] = 106; + out[1] = 1; + var j : usize = 0; + while (j < path.len and j < 103) { + out[2 + j] = path[j]; + j = j + 1; + } + return 106; +} + +/// Connect to the server whose socket lives under `home`; the fd, or -1 when nothing listens. +pub fn client_connect(home: []const u8) -> i32 { + var path : [128]u8 = undefined; + const pn : usize = socket_path(path[0..128], home); + if (pn == 0) { return -1; } + var addr : [106]u8 = undefined; + sockaddr_un(addr[0..106], path[0..pn]); + const fd : i32 = sys_socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { return -1; } + if (sys_connect(fd, addr[0..106], SOCKADDR_LEN) < 0) { + sys_close(fd); + return -1; + } + return fd; +} + +/// Read until one whole frame sits at the front of out; its length, 0 on a closed or bad connection. +pub fn client_recv(fd: i32, out: []u8) -> usize { + var have : usize = 0; + var need : usize = 0; + while (need == 0 or have < need) { + if (have >= MAX_FRAME) { return 0; } + const n : i64 = sys_read(fd, out[have .. MAX_FRAME], @as(u64, MAX_FRAME - have)); + if (n <= 0) { return 0; } + have = have + @as(usize, @intCast(n)); + need = frame_need(out[0 .. have]); + if (need == 1) { return 0; } + } + return need; +} + +/// One round trip: send the request frame, return the length of the reply frame now at the front of out. +pub fn client_call(fd: i32, op: u8, pane: u8, payload: []const u8, out: []u8) -> usize { + var req : [4200]u8 = undefined; + const n : usize = put_frame(req[0..4200], op, pane, payload); + if (n == 0) { return 0; } + const w : i64 = sys_write(fd, req[0..n], @as(u64, n)); + if (w <= 0) { return 0; } + if (@as(usize, @intCast(w)) != n) { return 0; } + return client_recv(fd, out); +} + +/// LIST: the reply frame (RE_LISTING, 4 bytes per pane). +pub fn client_list(fd: i32, out: []u8) -> usize { + return client_call(fd, OP_LIST, 0, "", out); +} + +/// SPAWN agent in `cwd` for a space/slot; the reply's pane byte is the new pane id. +pub fn client_spawn(fd: i32, agent: u32, space: u32, slot: u32, cwd: []const u8, out: []u8) -> usize { + var p : [300]u8 = undefined; + p[0] = @as(u8, @intCast(agent)); + p[1] = @as(u8, @intCast(space)); + p[2] = @as(u8, @intCast(slot)); + var i : usize = 0; + while (i < cwd.len and i < 290) { + p[3 + i] = cwd[i]; + i = i + 1; + } + return client_call(fd, OP_SPAWN, 0, p[0 .. 3 + i], out); +} + +/// ATTACH marks the pane seen; the reply's payload is the running byte count (the cursor to read from). +pub fn client_attach(fd: i32, pane: u32, out: []u8) -> usize { + return client_call(fd, OP_ATTACH, @as(u8, @intCast(pane)), "", out); +} + +pub fn client_detach(fd: i32, pane: u32, out: []u8) -> usize { + return client_call(fd, OP_DETACH, @as(u8, @intCast(pane)), "", out); +} + +/// KEYS: the bytes a user typed, written to the pane's terminal. +pub fn client_keys(fd: i32, pane: u32, keys: []const u8, out: []u8) -> usize { + return client_call(fd, OP_KEYS, @as(u8, @intCast(pane)), keys, out); +} + +/// READ from `cursor`: the reply payload is the new cursor (8 bytes) then the output bytes. +pub fn client_read(fd: i32, pane: u32, cursor: u64, out: []u8) -> usize { + var c : [8]u8 = undefined; + put_u64(c[0..8], 0, cursor); + return client_call(fd, OP_READ, @as(u8, @intCast(pane)), c[0..8], out); +} + +pub fn client_kill(fd: i32, pane: u32, out: []u8) -> usize { + return client_call(fd, OP_KILL, @as(u8, @intCast(pane)), "", out); +} + +/// RESIZE: the grid the window can show, applied to the pane's terminal and signalled with SIGWINCH. +pub fn client_resize(fd: i32, pane: u32, cols: u32, rows: u32, out: []u8) -> usize { + var p : [4]u8 = undefined; + put_u16(p[0..4], 0, cols); + put_u16(p[0..4], 2, rows); + return client_call(fd, OP_RESIZE, @as(u8, @intCast(pane)), p[0..4], out); +} + +/// The cursor a RE_DATA or RE_OK reply carries. +pub fn reply_cursor(frame: []const u8) -> u64 { + return get_u64(frame, HEADER); +} + +test the_sockaddr_holds_family_and_path { + var a : [106]u8 = undefined; + const n : usize = sockaddr_un(a[0..106], "/tmp/x.sock"); + assert n == 106; + assert a[0] == 106; + assert a[1] == 1; + assert a[2] == 47; + assert a[12] == 107; + assert a[13] == 0; + assert a[105] == 0; +} + +test a_long_path_is_cut_at_103 { + var a : [106]u8 = undefined; + var p : [200]u8 = undefined; + var i : usize = 0; + while (i < 200) { + p[i] = 97; + i = i + 1; + } + sockaddr_un(a[0..106], p[0..200]); + assert a[104] == 97; + assert a[105] == 0; +} diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 new file mode 100644 index 0000000000..125a7eb765 --- /dev/null +++ b/specs/t27os/pty_logic.t27 @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: Apache-2.0 +; T27 OS pty_logic: the decisions of the PTY server (pty_server.t27) that need no syscall -- pane slot +; allocation, pollfd packing, the status a hook or an exit gives, the command and argv a pane runs, the +; launchd plist. They live apart from the server because a module with @extern imports cannot run its +; tests (t27c needs two arguments for @extern; the JIT refuses imports), and a decision should run its tests. +; Refs gHashTag/t27#10782. +module T27osPtyLogic; + +use t27os::agents::AGENT_COUNT; +use t27os::agents::S_STOPPED; +use t27os::agents::R_SKIP; +use t27os::agents::R_RESUME; +use t27os::agents::R_RELAUNCH; +use t27os::pane_state::P_HOOK_START; +use t27os::pane_state::P_OUTPUT; +use t27os::pane_state::P_QUIET; +use t27os::pane_state::P_KEY; +use t27os::pane_state::P_SEEN; +use t27os::pane_state::P_EXIT_OK; +use t27os::pane_state::P_EXIT_FAIL; +use t27os::pane_state::P_SPAWNED; +use t27os::pane_state::P_COUNT; +use t27os::pane_state::BIT_UNSEEN; +use t27os::pane_state::ps_state; +use t27os::pane_state::ps_hooked; +use t27os::pane_state::P_HOOK_SUBMIT; +use t27os::pane_state::P_HOOK_STOP; +use t27os::agents::S_WORKING; +use t27os::pane_state::pane_step; +use t27os::pane_state::shown_state; +use t27os::pane_state::pane_needs_you; +use t27os::pane_state::quiet_applies; +use t27os::pane_state::is_quiet; +use t27os::pane_state::hook_event; +use t27os::session_store::SHELL_CELL; +use t27os::session_store::F_SPACE; +use t27os::session_store::F_AGENT; +use t27os::session_store::F_SLOT; +use t27os::session_store::F_STATE; +use t27os::session_store::F_SID_AT; +use t27os::session_store::F_SID_LEN; +use t27os::session_store::F_CWD_AT; +use t27os::session_store::F_CWD_LEN; +use t27os::session_store::F_COUNT; +use t27os::session_store::put_bytes; +use t27os::session_store::put_dec; +use t27os::session_store::put_record; +use t27os::session_store::parse_record; +use t27os::session_store::sid_ok; +use t27os::session_store::model_ok; +use t27os::session_store::restore_plan; +use t27os::session_store::build_command; +use t27os::session_store::fresh_command; +use t27os::session_store::json_field; +use t27os::session_store::record_path; +use t27os::session_store::uuid_text; +use t27os::session_store::hooks_json; +use t27os::pty_proto::OP_LIST; +use t27os::pty_proto::OP_SPAWN; +use t27os::pty_proto::OP_ATTACH; +use t27os::pty_proto::OP_DETACH; +use t27os::pty_proto::OP_KEYS; +use t27os::pty_proto::OP_READ; +use t27os::pty_proto::OP_KILL; +use t27os::pty_proto::OP_HOOK; +use t27os::pty_proto::RE_OK; +use t27os::pty_proto::RE_ERR; +use t27os::pty_proto::RE_DATA; +use t27os::pty_proto::RE_LISTING; +use t27os::pty_proto::HEADER; +use t27os::pty_proto::RING_CAP; +use t27os::pty_proto::MAX_PANES; +use t27os::pty_proto::get_u16; +use t27os::pty_proto::put_u64; +use t27os::pty_proto::get_u64; +use t27os::pty_proto::put_header; +use t27os::pty_proto::put_frame; +use t27os::pty_proto::frame_need; +use t27os::pty_proto::frame_complete; +use t27os::pty_proto::is_request; +use t27os::pty_proto::ring_window; +use t27os::pty_proto::ring_slot; +use t27os::pty_proto::data_chunk; +use t27os::pty_proto::ring_append; +use t27os::pty_proto::ring_copy; +use t27os::pty_proto::put_list_entry; +use t27os::pty_proto::spawn_ok; +use t27os::pty_proto::socket_path; + +// ---- the server's memory: one byte arena and one u64 table ---- + +/// u64 fields per pane in the table st; pane i starts at i * PF. +pub const PF : usize = 14; +pub const PF_USED : usize = 0; +pub const PF_LIVE : usize = 1; +pub const PF_PID : usize = 2; +pub const PF_FD : usize = 3; +pub const PF_AGENT : usize = 4; +pub const PF_SPACE : usize = 5; +pub const PF_SLOT : usize = 6; +pub const PF_STATUS : usize = 7; +pub const PF_TOTAL : usize = 8; +pub const PF_LAST : usize = 9; +pub const PF_SIDLEN : usize = 10; +pub const PF_CWDLEN : usize = 11; +pub const PF_BORN : usize = 12; +pub const PF_PLAN : usize = 13; +pub const RESUME_GRACE_MS : u64 = 8000; +pub const HOOK_STALE_MS : u64 = 30000; + +pub const G_LISTEN : usize = 224; +pub const G_HLEN : usize = 225; +pub const G_BINLEN : usize = 226; +pub const G_CFD : usize = 232; +pub const G_CLEN : usize = 240; +/// Each pane's last RESIZE, two panes per word (pty_proto ws_pack); 8 words cover MAX_PANES. +pub const G_WINSZ : usize = 248; +pub const ST_SIZE : usize = 256; +pub const MAX_CONNS : usize = 8; + +pub const SID_CAP : usize = 64; +pub const CWD_CAP : usize = 256; +pub const IN_CAP : usize = 8200; +pub const O_SID : usize = 0; +pub const O_CWD : usize = 1024; +pub const O_IN : usize = 5120; +pub const O_TMP1 : usize = 70720; +pub const O_TMP2 : usize = 78912; +pub const O_OUT : usize = 87104; +pub const O_HOME : usize = 91304; +pub const O_POLL : usize = 91560; +pub const O_MISC : usize = 92072; +pub const O_BIN : usize = 92400; +/// Each pane's model (session_store model_ok): a length byte, then up to MODEL_CAP - 1 bytes. +pub const MODEL_CAP : usize = 64; +pub const O_MODEL : usize = 97280; +pub const O_RING : usize = 98304; +pub const MEM_SIZE : usize = 1146880; +pub const TICK_MS : i32 = 500; +pub const SIGHUP_RING : u64 = 65536; + +// ---- pure decisions ---- + +/// The index of the first pane slot with no record; MAX_PANES when the table is full. +pub fn pane_alloc(st: []const u64) -> u32 { + var i : u32 = 0; + while (i < MAX_PANES) { + if (st[@as(usize, i) * PF + PF_USED] == 0) { return i; } + i = i + 1; + } + return MAX_PANES; +} + +/// The milliseconds a gettimeofday result holds: tv_sec (8 bytes) then tv_usec (4 bytes). +pub fn tv_ms(b: []const u8) -> u64 { + const sec : u64 = get_u64(b, 0); + const us : u64 = @as(u64, b[8]) + @as(u64, b[9]) * 256 + @as(u64, b[10]) * 65536 + @as(u64, b[11]) * 16777216; + return sec * 1000 + us / 1000; +} + +/// One pollfd entry k: the fd (4 bytes LE), events POLLIN, revents cleared. +pub fn put_pollfd(b: []u8, k: usize, fd: u32) -> void { + const at : usize = k * 8; + b[at] = @as(u8, @intCast(fd & 255)); + b[at + 1] = @as(u8, @intCast((fd / 256) & 255)); + b[at + 2] = @as(u8, @intCast((fd / 65536) & 255)); + b[at + 3] = @as(u8, @intCast((fd / 16777216) & 255)); + b[at + 4] = 1; + b[at + 5] = 0; + b[at + 6] = 0; + b[at + 7] = 0; +} + +/// The revents of entry k: anything but 0 means read it (data, hangup and error all show up as a read). +pub fn poll_revents(b: []const u8, k: usize) -> u32 { + return @as(u32, b[k * 8 + 6]) + @as(u32, b[k * 8 + 7]) * 256; +} + +/// The exit status word of waitpid: a clean exit(0) is P_EXIT_OK, anything else P_EXIT_FAIL. +pub fn exit_event(status: u32) -> u32 { + if (status == 0) { return P_EXIT_OK; } + return P_EXIT_FAIL; +} + +/// The status a hook event gives a pane; the status itself when the name is not one we read. +pub fn hooked_status(w: u32, name: []const u8) -> u32 { + const e : u32 = hook_event(name); + if (e == P_COUNT) { return w; } + return pane_step(w, e); +} + +/// The flags byte of a LISTING entry: bit 0 live, bit 1 needs you, bit 2 unseen. +pub fn list_flags(live: bool, w: u32) -> u32 { + var f : u32 = 0; + if (live) { f = f + 1; } + if (pane_needs_you(w)) { f = f + 2; } + if ((w & BIT_UNSEEN) != 0) { f = f + 4; } + return f; +} + +/// The model a hook payload names ({"model":"..."}: Claude Code's SessionStart carries it), as (at, len) +/// in f[0], f[1]; false when there is none or it fails model_ok(). +pub fn hook_model(payload: []const u8, f: []u64) -> bool { + if (!json_field(payload, "model", f)) { return false; } + const at : usize = @as(usize, @intCast(f[0])); + return model_ok(payload[at .. at + @as(usize, @intCast(f[1]))]); +} + +/// A SPAWN payload is agent, plan, 1 spare byte, then the cwd, optionally followed by a TAB and a model. +/// The length of the cwd part. +pub fn spawn_cwd_len(payload: []const u8) -> usize { + if (payload.len <= 3) { return 0; } + var i : usize = 3; + var end : usize = payload.len; + while (i < payload.len) { + if (payload[i] == 9 and end == payload.len) { end = i; } + i = i + 1; + } + return end - 3; +} + +/// The command a pane runs: fresh (with a new Claude session id) or resumed, plus the Claude hook +/// settings file for the claude cell, written into out. Returns the length. +pub fn pane_command(agent: u32, plan: u32, sid: []const u8, model: []const u8, home: []const u8, out: []u8) -> usize { + var n : usize = 0; + if (plan == R_RELAUNCH) { + n = fresh_command(agent, sid, model, out); + } else { + n = build_command(agent, plan, sid, model, out); + } + if (agent == 0) { + n = put_bytes(out, n, " --settings "); + n = put_bytes(out, n, home); + n = put_bytes(out, n, "/.t27os/hooks.json"); + } + return n; +} + +/// The argv strings of `/bin/sh -c "exec "` in one buffer, NUL separated; offsets go to f: +/// f[0] the -c at, f[1] the command at. Returns the buffer length. +pub fn argv_text(cmd: []const u8, out: []u8, f: []u64) -> usize { + var n : usize = put_bytes(out, 0, "/bin/sh"); + out[n] = 0; + f[0] = n + 1; + n = put_bytes(out, n + 1, "-c"); + out[n] = 0; + f[1] = n + 1; + n = put_bytes(out, n + 1, "exec "); + n = put_bytes(out, n, cmd); + out[n] = 0; + return n + 1; +} + +/// Text that can stand inside a plist as it is: no markup character, no control byte. +pub fn xml_ok(s: []const u8) -> bool { + var i : usize = 0; + while (i < s.len) { + if (s[i] == 60 or s[i] == 62 or s[i] == 38 or s[i] < 32) { return false; } + i = i + 1; + } + return true; +} + +fn plist_env(out: []u8, at: usize, key: []const u8, val: []const u8) -> usize { + if (val.len == 0 or !xml_ok(val)) { return at; } + var n : usize = put_bytes(out, at, ""); + n = put_bytes(out, n, key); + n = put_bytes(out, n, ""); + n = put_bytes(out, n, val); + return put_bytes(out, n, ""); +} + +/// The launchd agent plist that starts this binary at login and restarts it when it dies. launchd gives +/// an agent PATH=/usr/bin:/bin:/usr/sbin:/sbin and no TERM, so the plist carries the PATH and LANG of the +/// shell that installed it (claude lives in ~/.local/bin) and TERM=xterm-256color for the panes; the +/// server's output goes to ~/.t27os/server.log. A value that is not xml_ok() is left out. +pub fn plist_text(out: []u8, bin: []const u8, home: []const u8, path: []const u8, lang: []const u8) -> usize { + var n : usize = put_bytes(out, 0, "\n"); + n = put_bytes(out, n, "\n"); + n = put_bytes(out, n, "Labelai.t27.os-server\n"); + n = put_bytes(out, n, "ProgramArguments"); + n = put_bytes(out, n, bin); + n = put_bytes(out, n, "\n"); + n = put_bytes(out, n, "RunAtLoad\n"); + n = put_bytes(out, n, "KeepAlive\n"); + n = put_bytes(out, n, "EnvironmentVariables"); + n = plist_env(out, n, "PATH", path); + n = plist_env(out, n, "LANG", lang); + n = plist_env(out, n, "TERM", "xterm-256color"); + n = put_bytes(out, n, "\n"); + if (xml_ok(home)) { + n = put_bytes(out, n, "StandardOutPath"); + n = put_bytes(out, n, home); + n = put_bytes(out, n, "/.t27os/server.log\n"); + n = put_bytes(out, n, "StandardErrorPath"); + n = put_bytes(out, n, home); + n = put_bytes(out, n, "/.t27os/server.log\n"); + } + n = put_bytes(out, n, "\n"); + return n; +} + +/// A resumed pane that dies at once (no conversation to resume: claude was never prompted, or its +/// transcript is gone) is relaunched fresh in its cwd: the exit came before the grace period ended. +pub fn resume_failed(plan: u32, born: u64, now: u64, status: u32) -> bool { + if (plan != R_RESUME or status == 0) { return false; } + return now >= born and now - born < RESUME_GRACE_MS; +} + +/// A hooked pane that says "working" and has printed nothing for HOOK_STALE_MS lost its Stop hook (an +/// empty Enter at the prompt starts no turn): treat the turn as finished. +pub fn hook_stale(w: u32, now: u64, last: u64) -> bool { + if (!ps_hooked(w) or ps_state(w) != S_WORKING) { return false; } + return now >= last and now - last >= HOOK_STALE_MS; +} + +/// Copy a NUL-free path and add the NUL a C call needs; returns the length without it. +pub fn c_path(out: []u8, src: []const u8) -> usize { + const n : usize = put_bytes(out, 0, src); + out[n] = 0; + return n; +} + + +// ---- tests: each runs and asserts on values the decisions compute ---- + +test pane_alloc_takes_the_first_free_slot { + var st : [224]u64 = undefined; + var i : usize = 0; + while (i < 224) { + st[i] = 0; + i = i + 1; + } + assert pane_alloc(st[0..224]) == 0; + st[0 * PF + PF_USED] = 1; + st[1 * PF + PF_USED] = 1; + assert pane_alloc(st[0..224]) == 2; + st[0 * PF + PF_USED] = 0; + assert pane_alloc(st[0..224]) == 0; + var k : usize = 0; + while (k < 16) { + st[k * PF + PF_USED] = 1; + k = k + 1; + } + assert pane_alloc(st[0..224]) == 16; +} + +test tv_ms_adds_seconds_and_micros { + var b : [16]u8 = undefined; + var i : usize = 0; + while (i < 16) { + b[i] = 0; + i = i + 1; + } + b[0] = 10; + b[8] = 208; + b[9] = 7; + assert tv_ms(b[0..16]) == 10002; + b[8] = 63; + b[9] = 66; + b[10] = 15; + assert tv_ms(b[0..16]) == 10000 + 999; +} + +test pollfd_round_trips_a_big_fd { + var b : [32]u8 = undefined; + var i : usize = 0; + while (i < 32) { + b[i] = 255; + i = i + 1; + } + put_pollfd(b[0..32], 1, 66051); + assert b[8] == 3; + assert b[9] == 2; + assert b[10] == 1; + assert b[11] == 0; + assert b[12] == 1; + assert b[13] == 0; + assert poll_revents(b[0..32], 1) == 0; + b[14] = 16; + assert poll_revents(b[0..32], 1) == 16; + assert b[7] == 255; +} + +test exit_and_hook_events_pick_the_right_status { + assert exit_event(0) == P_EXIT_OK; + assert exit_event(256) == P_EXIT_FAIL; + const w : u32 = pane_step(0, P_SPAWNED); + assert ps_state(hooked_status(w, "UserPromptSubmit")) == 1; + assert ps_state(hooked_status(w, "Notification")) == 2; + assert hooked_status(w, "SomethingElse") == w; + assert list_flags(true, hooked_status(w, "Notification")) == 3; + assert list_flags(false, w) == 0; +} + +test the_command_carries_the_resume_flag_and_the_hook_settings { + var out : [200]u8 = undefined; + const n : usize = pane_command(0, R_RESUME, "5f1c9a00-aaaa-bbbb-cccc-0123456789ab", "", "/Users/x", out[0..200]); + var want : [200]u8 = undefined; + const m : usize = put_bytes(want[0..200], 0, "claude --resume 5f1c9a00-aaaa-bbbb-cccc-0123456789ab --settings /Users/x/.t27os/hooks.json"); + assert n == m; + var i : usize = 0; + var same : bool = true; + while (i < n) { + if (out[i] != want[i]) { same = false; } + i = i + 1; + } + assert same; + const b : usize = pane_command(SHELL_CELL, R_RELAUNCH, "-", "opus", "/Users/x", out[0..200]); + assert b == 9; + assert out[5] == 98; +} + +test the_argv_buffer_holds_three_strings { + var out : [200]u8 = undefined; + var f : [2]u64 = undefined; + const n : usize = argv_text("codex", out[0..200], f[0..2]); + assert out[7] == 0; + assert f[0] == 8; + assert out[9] == 99; + assert out[10] == 0; + assert f[1] == 11; + assert out[11] == 101; + assert out[15] == 32; + assert out[16] == 99; + assert out[20] == 120; + assert out[21] == 0; + assert n == 22; +} + +/// How many times needle occurs in hay (the plist test reads its output with it). +fn pl_count(hay: []const u8, needle: []const u8) -> usize { + var c : usize = 0; + var i : usize = 0; + while (i + needle.len <= hay.len) { + var j : usize = 0; + while (j < needle.len and hay[i + j] == needle[j]) { j = j + 1; } + if (j == needle.len) { c = c + 1; } + i = i + 1; + } + return c; +} + +test the_plist_names_the_binary_and_keeps_it_alive { + var out : [1200]u8 = undefined; + const n : usize = plist_text(out[0..1200], "/Users/x/.t27os/t27os-pty", "/Users/x", "/Users/x/.local/bin:/usr/bin:/bin", "en_US.UTF-8"); + assert n > 400; + assert out[0] == 60; + assert out[n - 1] == 10; + assert pl_count(out[0..n], "/Users/x/.t27os/t27os-pty") == 1; + assert pl_count(out[0..n], "RunAtLoad") == 1; + assert pl_count(out[0..n], "KeepAlive") == 1; + // launchd's own PATH has no ~/.local/bin: the installer's PATH goes with the agent + assert pl_count(out[0..n], "PATH/Users/x/.local/bin:/usr/bin:/bin") == 1; + assert pl_count(out[0..n], "LANGen_US.UTF-8") == 1; + assert pl_count(out[0..n], "TERMxterm-256color") == 1; + assert pl_count(out[0..n], "/Users/x/.t27os/server.log") == 2; + assert pl_count(out[0..n], "") == 2; + assert pl_count(out[0..n], "") == 2; + // a value that would break the XML is left out, the rest stays + const m : usize = plist_text(out[0..1200], "/b", "/h", "/aPATH") == 0; + assert pl_count(out[0..m], "LANG") == 0; + assert pl_count(out[0..m], "TERM") == 1; + assert xml_ok("/usr/bin:/bin"); + assert xml_ok("a&b") == false; + assert xml_ok("a>b") == false; +} + +test the_session_start_hook_gives_the_model { + var f : [2]u64 = undefined; + const p : str = "{\"session_id\":\"abc-1\",\"hook_event_name\":\"SessionStart\",\"model\":\"claude-opus-5-5\"}"; + assert hook_model(p, f[0..2]); + assert f[1] == 15; + assert p[@as(usize, @intCast(f[0]))] == 99; + assert hook_model("{\"session_id\":\"abc-1\"}", f[0..2]) == false; + assert hook_model("{\"model\":\"a b\"}", f[0..2]) == false; + // an object, not a string: nothing to save + assert hook_model("{\"model\":{\"id\":\"opus\"}}", f[0..2]) == false; +} + +test a_pane_command_keeps_its_model { + var out : [200]u8 = undefined; + const n : usize = pane_command(0, R_RESUME, "abc-1", "opus", "/h", out[0..200]); + // claude --resume abc-1 --model 'opus' --settings /h/.t27os/hooks.json + assert n == 36 + 12 + 2 + 18; + assert pl_count(out[0..n], " --model 'opus' --settings ") == 1; + const r : usize = pane_command(0, R_RELAUNCH, "5f1c2c6e-9a41-4f0b-8c5d-0d3e5a7b9c11", "opus", "/h", out[0..200]); + assert pl_count(out[0..r], "claude --session-id 5f1c2c6e-9a41-4f0b-8c5d-0d3e5a7b9c11 --model 'opus' --settings ") == 1; + // no model: the line is the one before v2 + const b : usize = pane_command(0, R_RESUME, "abc-1", "", "/h", out[0..200]); + assert b == 21 + 12 + 2 + 18; + assert O_MODEL + 16 * MODEL_CAP <= O_RING; + assert O_BIN + 256 <= O_MODEL; +} + +test a_spawn_payload_splits_cwd_and_model { + assert spawn_cwd_len("abc/Users/me") == 9; + assert spawn_cwd_len("abc/Users/me\topus") == 9; + assert spawn_cwd_len("abc") == 0; + assert spawn_cwd_len("abc\topus") == 0; +} + + +test c_path_adds_the_nul { + var out : [20]u8 = undefined; + out[3] = 255; + const n : usize = c_path(out[0..20], "abc"); + assert n == 3; + assert out[3] == 0; + assert out[2] == 99; +} + +test a_resume_that_dies_at_once_relaunches_fresh { + assert resume_failed(R_RESUME, 1000, 2500, 256); + assert !resume_failed(R_RESUME, 1000, 2500, 0); + assert !resume_failed(R_RESUME, 1000, 20000, 256); + assert !resume_failed(R_RELAUNCH, 1000, 2500, 256); + assert !resume_failed(R_RESUME, 5000, 1000, 256); +} + +test a_hooked_working_pane_that_went_silent_is_finished { + var w : u32 = pane_step(pane_step(0, P_SPAWNED), P_HOOK_START); + w = pane_step(w, P_HOOK_SUBMIT); + assert !hook_stale(w, 20000, 1000); + assert hook_stale(w, 31000, 1000); + assert !hook_stale(pane_step(0, P_SPAWNED), 99999, 0); + assert !hook_stale(pane_step(w, P_HOOK_STOP), 99999, 0); +} diff --git a/specs/t27os/pty_proto.t27 b/specs/t27os/pty_proto.t27 new file mode 100644 index 0000000000..e8c8a53c1b --- /dev/null +++ b/specs/t27os/pty_proto.t27 @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/pty_proto.t27 -- the wire format between the T27 OS PTY server and its clients, and the +; scrollback ring each pane keeps. pty_server.t27 and pty_client.t27 both `use` this module, so the two ends +; cannot disagree about a byte. +; Owner, 2026-10-19: agents run as real terminals owned by T27 OS; the window attaches and detaches, the +; panes keep running. +; The socket is ~/.t27os/pty.sock (unix stream). Every message is one frame: +; [op u8][pane u8][len u16 little-endian][payload, len bytes] len <= MAX_PAYLOAD +; Requests (client to server): LIST, SPAWN, ATTACH, DETACH, KEYS, RESIZE, READ, KILL, HOOK. +; Replies (server to client) carry bit 7: OK, ERR, DATA, LISTING. +; SPAWN payload: [agent u8][space u8][slot u8] then the cwd bytes. Reply OK with the new pane in `pane`. +; KEYS payload: the bytes to write to the pane's PTY. +; RESIZE payload: [cols u16 LE][rows u16 LE]. +; READ payload: [cursor u64 LE]. Reply DATA: [new cursor u64 LE] then the bytes since `cursor`; a cursor +; older than the ring holds is clamped to the oldest byte still kept (ring_window). +; LIST reply LISTING: one 4-byte entry per pane [pane][agent][shown state][flags] (list_entry). +; HOOK payload: [event name length u8][event name][hook JSON payload]; pane is the pane the hook ran in. +; The scrollback of a pane is a ring of RING_CAP bytes addressed by a running byte count `total`; the byte +; with count n lives at n % RING_CAP. A client's cursor is also a running count, so it survives a detach. + +module T27osPtyProto; + +pub const OP_LIST : u8 = 1; +pub const OP_SPAWN : u8 = 2; +pub const OP_ATTACH : u8 = 3; +pub const OP_DETACH : u8 = 4; +pub const OP_KEYS : u8 = 5; +pub const OP_RESIZE : u8 = 6; +pub const OP_READ : u8 = 7; +pub const OP_KILL : u8 = 8; +pub const OP_HOOK : u8 = 9; +pub const RE_OK : u8 = 128; +pub const RE_ERR : u8 = 129; +pub const RE_DATA : u8 = 130; +pub const RE_LISTING : u8 = 131; + +pub const HEADER : usize = 4; +pub const MAX_PAYLOAD : usize = 4096; +pub const MAX_FRAME : usize = 4100; +pub const RING_CAP : u64 = 65536; +pub const MAX_PANES : u32 = 16; +/// sun_path holds 104 bytes on macOS; the socket path must leave room for the terminating zero. +pub const SUN_PATH_MAX : usize = 103; + +pub fn put_u16(out: []u8, at: usize, v: u32) -> usize { + out[at] = @as(u8, @intCast(v & 255)); + out[at + 1] = @as(u8, @intCast((v >> 8) & 255)); + return at + 2; +} + +pub fn get_u16(b: []const u8, at: usize) -> u32 { + return @as(u32, b[at]) + @as(u32, b[at + 1]) * 256; +} + +pub fn put_u64(out: []u8, at: usize, v: u64) -> usize { + var i : usize = 0; + var x : u64 = v; + while (i < 8) { + out[at + i] = @as(u8, @intCast(x & 255)); + x = x >> 8; + i = i + 1; + } + return at + 8; +} + +pub fn get_u64(b: []const u8, at: usize) -> u64 { + var v : u64 = 0; + var i : usize = 8; + while (i > 0) { + i = i - 1; + v = v * 256 + @as(u64, b[at + i]); + } + return v; +} + +/// Write the header of a frame whose payload has `len` bytes; returns HEADER. +pub fn put_header(out: []u8, op: u8, pane: u8, len: usize) -> usize { + out[0] = op; + out[1] = pane; + return put_u16(out, 2, @as(u32, @intCast(len))); +} + +/// Write a whole frame (header and payload); returns its length, 0 when the payload is too long. +pub fn put_frame(out: []u8, op: u8, pane: u8, payload: []const u8) -> usize { + if (payload.len > MAX_PAYLOAD) { return 0; } + var n : usize = put_header(out, op, pane, payload.len); + var i : usize = 0; + while (i < payload.len) { + out[n + i] = payload[i]; + i = i + 1; + } + return n + payload.len; +} + +/// How many bytes the frame at the start of buf needs in all: 0 when the header is not here yet, 1 when +/// the declared payload is longer than MAX_PAYLOAD (the connection is bad and is dropped), else the length. +pub fn frame_need(buf: []const u8) -> usize { + if (buf.len < HEADER) { return 0; } + const len : usize = @as(usize, get_u16(buf, 2)); + if (len > MAX_PAYLOAD) { return 1; } + return HEADER + len; +} + +/// True when buf holds a whole frame. +pub fn frame_complete(buf: []const u8) -> bool { + const need : usize = frame_need(buf); + return need > 1 and buf.len >= need; +} + +pub fn is_request(op: u8) -> bool { return op >= OP_LIST and op <= OP_HOOK; } +pub fn is_reply(op: u8) -> bool { return op >= RE_OK and op <= RE_LISTING; } + +/// The running count of the oldest byte still in the ring and how many bytes a reader at `cursor` gets, +/// as (start, count): a stale cursor is clamped, a cursor past the end gets nothing. +pub fn ring_window(total: u64, cursor: u64, f: []u64) -> bool { + var start : u64 = cursor; + if (total > RING_CAP and start < total - RING_CAP) { start = total - RING_CAP; } + if (start > total) { start = total; } + f[0] = start; + f[1] = total - start; + return start != cursor; +} + +/// Where in the ring the byte with running count n lives. +pub fn ring_slot(n: u64) -> usize { + return @as(usize, @intCast(n % RING_CAP)); +} + +/// The number of bytes of a window that fit in one DATA reply: the payload also holds the 8-byte cursor. +pub fn data_chunk(avail: u64) -> usize { + const room : usize = MAX_PAYLOAD - 8; + if (avail > @as(u64, room)) { return room; } + return @as(usize, @intCast(avail)); +} + +/// Append n bytes to a ring (a RING_CAP-byte buffer) that has seen `total` bytes; returns the new total. +pub fn ring_append(ring: []u8, total: u64, src: []const u8, n: usize) -> u64 { + var i : usize = 0; + var t : u64 = total; + while (i < n) { + ring[ring_slot(t)] = src[i]; + t = t + 1; + i = i + 1; + } + return t; +} + +/// Copy the window of a ring that starts at count `start` into out; returns the number of bytes copied. +pub fn ring_copy(ring: []const u8, start: u64, n: usize, out: []u8) -> usize { + var i : usize = 0; + while (i < n) { + out[i] = ring[ring_slot(start + @as(u64, i))]; + i = i + 1; + } + return n; +} + +/// One LISTING entry. +pub fn put_list_entry(out: []u8, at: usize, pane: u32, agent: u32, shown: u32, flags: u32) -> usize { + out[at] = @as(u8, @intCast(pane & 255)); + out[at + 1] = @as(u8, @intCast(agent & 255)); + out[at + 2] = @as(u8, @intCast(shown & 255)); + out[at + 3] = @as(u8, @intCast(flags & 255)); + return at + 4; +} + +/// The cwd of a SPAWN payload [agent][space][slot][cwd...] lives at 3; false when it is too short or the +/// cwd holds a NUL, a newline or a space-free-ness the manifest could not keep (NUL and newline). +pub fn spawn_ok(payload: []const u8) -> bool { + if (payload.len < 4) { return false; } + var i : usize = 3; + while (i < payload.len) { + if (payload[i] == 0 or payload[i] == 10) { return false; } + i = i + 1; + } + return payload[3] == 47; +} + +/// RESIZE bounds. A pane narrower than 2 or wider than RS_COLS_MAX is a broken client, not a window. +pub const RS_COLS_MAX : u32 = 500; +pub const RS_ROWS_MAX : u32 = 300; +/// Darwin's TIOCSWINSZ, _IOW('t', 103, struct winsize) = 0x80087467; SIGWINCH is 28. +pub const TIOCSWINSZ : u64 = 2148037735; +pub const SIGWINCH : i32 = 28; + +/// A RESIZE payload is exactly [cols u16 LE][rows u16 LE], both inside the bounds. +pub fn resize_ok(payload: []const u8) -> bool { + if (payload.len != 4) { return false; } + const c : u32 = get_u16(payload, 0); + const r : u32 = get_u16(payload, 2); + return c >= 2 and c <= RS_COLS_MAX and r >= 2 and r <= RS_ROWS_MAX; +} + +/// struct winsize as the kernel reads it: [ws_row u16][ws_col u16][xpixel u16][ypixel u16], pixels 0. +pub fn put_winsize(out: []u8, cols: u32, rows: u32) -> usize { + put_u16(out, 0, rows); + put_u16(out, 2, cols); + put_u16(out, 4, 0); + put_u16(out, 6, 0); + return 8; +} + +/// The server keeps each pane's last size in one u64 per two panes: half h (0 or 1) holds +/// [cols u16][rows u16] in bits 32h..32h+31. Zero means the pane was never resized: 80x24. +pub fn ws_pack(word: u64, h: u32, cols: u32, rows: u32) -> u64 { + const v : u64 = @as(u64, cols & 65535) | (@as(u64, rows & 65535) << 16); + const lo : u64 = word & 4294967295; + const hi : u64 = (word >> 32) & 4294967295; + if (h == 0) { return (hi << 32) | v; } + return (v << 32) | lo; +} + +fn ws_half(word: u64, h: u32) -> u64 { + if (h == 0) { return word & 4294967295; } + return (word >> 32) & 4294967295; +} + +pub fn ws_cols(word: u64, h: u32) -> u32 { + const c : u32 = @as(u32, @intCast(ws_half(word, h) & 65535)); + if (c == 0) { return 80; } + return c; +} + +pub fn ws_rows(word: u64, h: u32) -> u32 { + const r : u32 = @as(u32, @intCast((ws_half(word, h) >> 16) & 65535)); + if (r == 0) { return 24; } + return r; +} + +/// Length of the socket path /.t27os/pty.sock, or 0 when it would not fit in sun_path. +pub fn socket_path(out: []u8, home: []const u8) -> usize { + const tail : []const u8 = "/.t27os/pty.sock"; + if (home.len + tail.len > SUN_PATH_MAX) { return 0; } + var i : usize = 0; + while (i < home.len) { + out[i] = home[i]; + i = i + 1; + } + var j : usize = 0; + while (j < tail.len) { + out[i + j] = tail[j]; + j = j + 1; + } + return i + tail.len; +} + +test a_frame_round_trips_through_a_partial_read { + var buf : [MAX_FRAME]u8 = undefined; + var pay : [10]u8 = undefined; + var i : usize = 0; + while (i < 10) { pay[i] = @as(u8, @intCast(65 + i)); i = i + 1; } + const n : usize = put_frame(buf[0..MAX_FRAME], OP_KEYS, 3, pay[0..10]); + assert n == 14; + assert buf[0] == OP_KEYS and buf[1] == 3; + assert get_u16(buf[0..MAX_FRAME], 2) == 10; + var cut : usize = 0; + while (cut < n) { + assert frame_complete(buf[0..cut]) == false; + cut = cut + 1; + } + assert frame_complete(buf[0..n]); + assert frame_need(buf[0..n]) == 14; + assert buf[HEADER + 9] == 74; +} + +test an_oversized_frame_is_refused_at_both_ends { + var buf : [MAX_FRAME + 8]u8 = undefined; + var big : [4200]u8 = undefined; + assert put_frame(buf[0..MAX_FRAME], OP_KEYS, 0, big[0..4097]) == 0; + assert put_frame(buf[0..MAX_FRAME], OP_KEYS, 0, big[0..4096]) == 4100; + var h : usize = put_header(buf[0..MAX_FRAME], OP_KEYS, 0, 5000); + assert h == HEADER; + assert frame_need(buf[0..HEADER]) == 1; + assert frame_complete(buf[0..HEADER]) == false; +} + +test ops_are_split_into_requests_and_replies { + var op : u8 = 0; + var requests : u32 = 0; + var replies : u32 = 0; + while (op < 200) { + if (is_request(op)) { requests = requests + 1; } + if (is_reply(op)) { replies = replies + 1; } + assert !(is_request(op) and is_reply(op)); + op = op + 1; + } + assert requests == 9; + assert replies == 4; +} + +test numbers_are_little_endian { + var b : [10]u8 = undefined; + assert put_u16(b[0..10], 0, 513) == 2; + assert b[0] == 1 and b[1] == 2; + assert get_u16(b[0..10], 0) == 513; + var v : u64 = 1234567890123; + assert put_u64(b[0..10], 1, v) == 9; + assert b[1] == 203; + assert get_u64(b[0..10], 1) == v; + v = 18446744073709551615; + assert put_u64(b[0..10], 0, v) == 8; + assert get_u64(b[0..10], 0) == v; +} + +test a_reader_gets_what_it_missed_and_never_stale_bytes { + var f : [2]u64 = undefined; + // nothing new + assert ring_window(100, 100, f[0..2]) == false; + assert f[0] == 100 and f[1] == 0; + // 40 new bytes + assert ring_window(100, 60, f[0..2]) == false; + assert f[1] == 40; + // a cursor older than the ring holds is moved forward + assert ring_window(RING_CAP + 1000, 10, f[0..2]); + assert f[0] == 1000 and f[1] == RING_CAP; + // a cursor from the future (the server restarted with an empty ring) is pulled back + assert ring_window(50, 900, f[0..2]); + assert f[0] == 50 and f[1] == 0; +} + +test the_ring_keeps_the_last_cap_bytes { + var ring : [65536]u8 = undefined; + var chunk : [100]u8 = undefined; + var i : usize = 0; + while (i < 100) { chunk[i] = @as(u8, @intCast(i)); i = i + 1; } + var total : u64 = 0; + var k : u32 = 0; + while (k < 700) { + total = ring_append(ring[0..65536], total, chunk[0..100], 100); + k = k + 1; + } + assert total == 70000; + var f : [2]u64 = undefined; + assert ring_window(total, 0, f[0..2]); + assert f[0] == 70000 - RING_CAP; + var out : [100]u8 = undefined; + // the byte with count 70000 - 100 was the last byte of a chunk: value 99 + assert ring_copy(ring[0..65536], total - 100, 100, out[0..100]) == 100; + assert out[0] == 0 and out[99] == 99; + // the oldest byte still kept is chunk byte (70000 - 65536) % 100 = 64 + assert ring_copy(ring[0..65536], f[0], 1, out[0..100]) == 1; + assert out[0] == 64; + assert ring_slot(65536) == 0; + assert ring_slot(65537) == 1; +} + +test a_data_reply_never_overflows_a_frame { + assert data_chunk(0) == 0; + assert data_chunk(100) == 100; + assert data_chunk(4088) == 4088; + assert data_chunk(4089) == 4088; + assert data_chunk(65536) == 4088; + assert 8 + data_chunk(65536) <= MAX_PAYLOAD; +} + +test listing_entries_are_four_bytes { + var b : [20]u8 = undefined; + var at : usize = 0; + at = put_list_entry(b[0..20], at, 2, 0, 4, 1); + at = put_list_entry(b[0..20], at, 3, 7, 1, 0); + assert at == 8; + assert b[0] == 2 and b[1] == 0 and b[2] == 4 and b[3] == 1; + assert b[4] == 3 and b[5] == 7; +} + +test spawn_payloads_are_checked_before_use { + var p : [40]u8 = undefined; + var i : usize = 0; + while (i < 40) { p[i] = 120; i = i + 1; } + p[3] = 47; + assert spawn_ok(p[0..10]); + assert spawn_ok(p[0..3]) == false; + p[6] = 10; + assert spawn_ok(p[0..10]) == false; + p[6] = 0; + assert spawn_ok(p[0..10]) == false; + p[6] = 120; + p[3] = 114; + assert spawn_ok(p[0..10]) == false; +} + +test the_socket_path_fits_sun_path { + var out : [120]u8 = undefined; + const n : usize = socket_path(out[0..120], "/Users/tester"); + assert n == 29; + assert out[n - 1] == 107; + var long : [100]u8 = undefined; + var i : usize = 0; + while (i < 100) { long[i] = 97; i = i + 1; } + assert socket_path(out[0..120], long[0..87]) == 103; + assert socket_path(out[0..120], long[0..88]) == 0; +} + +test resize_payloads_and_sizes_round_trip { + var p : [8]u8 = undefined; + put_u16(p[0..8], 0, 239); + put_u16(p[0..8], 2, 61); + assert resize_ok(p[0..4]); + assert resize_ok(p[0..3]) == false; + assert resize_ok(p[0..5]) == false; + put_u16(p[0..8], 0, 1); + assert resize_ok(p[0..4]) == false; + put_u16(p[0..8], 0, RS_COLS_MAX + 1); + assert resize_ok(p[0..4]) == false; + var w : [8]u8 = undefined; + assert put_winsize(w[0..8], 239, 61) == 8; + assert get_u16(w[0..8], 0) == 61; + assert get_u16(w[0..8], 2) == 239; + assert get_u16(w[0..8], 6) == 0; + assert ws_cols(0, 0) == 80; + assert ws_rows(0, 1) == 24; + const a : u64 = ws_pack(0, 1, 239, 61); + assert ws_cols(a, 1) == 239; + assert ws_rows(a, 1) == 61; + assert ws_cols(a, 0) == 80; + const b : u64 = ws_pack(a, 0, 100, 30); + assert ws_cols(b, 0) == 100; + assert ws_rows(b, 0) == 30; + assert ws_cols(b, 1) == 239; + const c : u64 = ws_pack(b, 1, 120, 40); + assert ws_rows(c, 1) == 40; + assert ws_cols(c, 0) == 100; + assert TIOCSWINSZ == 2148007936 + 29799; +} diff --git a/specs/t27os/pty_server.t27 b/specs/t27os/pty_server.t27 new file mode 100644 index 0000000000..d2d64bd50e --- /dev/null +++ b/specs/t27os/pty_server.t27 @@ -0,0 +1,931 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS pty_server: the long-lived process that owns one pseudo-terminal per pane, so an agent CLI +// (Claude Code, Codex, a shell) keeps running when the window closes or crashes (the herdr.dev model). +// It spawns the pane's command in the pane's cwd, keeps a 64 KiB scrollback ring per pane, serves the +// frames of pty_proto.t27 on ~/.t27os/pty.sock, and writes one record per pane under ~/.t27os/sessions +// (session_store.t27) on every change. When the server dies, its masters close, the panes get SIGHUP, and +// the next start reads the records and brings every pane back by the agents.t27 RESUME_* rules. +// +// One binary, three modes, picked by the environment so no argv parsing is needed: +// (none) serve +// T27OS_HOOK=1 a Claude Code hook: read the payload on stdin, forward it as OP_HOOK to pane +// T27OS_PANE, exit. The server writes ~/.t27os/hooks.json so claude panes run it. +// T27OS_CTL= a tiny client for scripts and the live test: list, spawn, kill, read, keys +// (T27OS_AGENT, T27OS_CWD, T27OS_MODEL, T27OS_PANE, T27OS_KEYS carry the arguments). +// T27OS_INSTALL=1 write ~/Library/LaunchAgents/ai.t27.os-server.plist for this binary: it starts +// the server at login, so panes come back (by session id, model kept) after a reboot. +// +// Every decision is a fn with a test that runs; the host loop only moves bytes through pty_sys.t27. +// +// Refs gHashTag/t27#10782. +module T27osPtyServer; + +use t27os::agents::AGENT_COUNT; +use t27os::agents::S_STOPPED; +use t27os::agents::R_SKIP; +use t27os::agents::R_RESUME; +use t27os::agents::R_RELAUNCH; +use t27os::pane_state::P_HOOK_START; +use t27os::pane_state::P_OUTPUT; +use t27os::pane_state::P_QUIET; +use t27os::pane_state::P_KEY; +use t27os::pane_state::P_SEEN; +use t27os::pane_state::P_EXIT_OK; +use t27os::pane_state::P_EXIT_FAIL; +use t27os::pane_state::P_SPAWNED; +use t27os::pane_state::P_COUNT; +use t27os::pane_state::BIT_UNSEEN; +use t27os::pane_state::ps_state; +use t27os::pane_state::pane_step; +use t27os::pane_state::shown_state; +use t27os::pane_state::pane_needs_you; +use t27os::pane_state::quiet_applies; +use t27os::pane_state::is_quiet; +use t27os::pane_state::hook_event; +use t27os::session_store::SHELL_CELL; +use t27os::session_store::F_SPACE; +use t27os::session_store::F_AGENT; +use t27os::session_store::F_SLOT; +use t27os::session_store::F_STATE; +use t27os::session_store::F_SID_AT; +use t27os::session_store::F_SID_LEN; +use t27os::session_store::F_CWD_AT; +use t27os::session_store::F_CWD_LEN; +use t27os::session_store::F_COUNT; +use t27os::session_store::F_MODEL_AT; +use t27os::session_store::F_MODEL_LEN; +use t27os::session_store::model_ok; +use t27os::session_store::transcript_path; +use t27os::session_store::last_model; +use t27os::session_store::put_bytes; +use t27os::session_store::put_dec; +use t27os::session_store::put_record; +use t27os::session_store::parse_record; +use t27os::session_store::sid_ok; +use t27os::session_store::restore_plan; +use t27os::session_store::build_command; +use t27os::session_store::fresh_command; +use t27os::session_store::json_field; +use t27os::session_store::record_path; +use t27os::session_store::uuid_text; +use t27os::session_store::hooks_json; +use t27os::pty_proto::OP_LIST; +use t27os::pty_proto::OP_SPAWN; +use t27os::pty_proto::OP_ATTACH; +use t27os::pty_proto::OP_DETACH; +use t27os::pty_proto::OP_KEYS; +use t27os::pty_proto::OP_READ; +use t27os::pty_proto::OP_KILL; +use t27os::pty_proto::OP_HOOK; +use t27os::pty_proto::RE_OK; +use t27os::pty_proto::RE_ERR; +use t27os::pty_proto::RE_DATA; +use t27os::pty_proto::RE_LISTING; +use t27os::pty_proto::HEADER; +use t27os::pty_proto::RING_CAP; +use t27os::pty_proto::MAX_PANES; +use t27os::pty_proto::get_u16; +use t27os::pty_proto::OP_RESIZE; +use t27os::pty_proto::TIOCSWINSZ; +use t27os::pty_proto::SIGWINCH; +use t27os::pty_proto::resize_ok; +use t27os::pty_proto::put_winsize; +use t27os::pty_proto::ws_pack; +use t27os::pty_proto::ws_cols; +use t27os::pty_proto::ws_rows; +use t27os::pty_proto::put_u64; +use t27os::pty_proto::get_u64; +use t27os::pty_proto::put_header; +use t27os::pty_proto::put_frame; +use t27os::pty_proto::frame_need; +use t27os::pty_proto::frame_complete; +use t27os::pty_proto::is_request; +use t27os::pty_proto::ring_window; +use t27os::pty_proto::ring_slot; +use t27os::pty_proto::data_chunk; +use t27os::pty_proto::ring_append; +use t27os::pty_proto::ring_copy; +use t27os::pty_proto::put_list_entry; +use t27os::pty_proto::spawn_ok; +use t27os::pty_proto::socket_path; +use t27os::pty_sys::sys_write; +use t27os::pty_sys::sys_read; +use t27os::pty_sys::sys_close; +use t27os::pty_sys::sys_open_ro; +use t27os::pty_sys::sys_creat; +use t27os::pty_sys::sys_rename; +use t27os::pty_sys::sys_unlink; +use t27os::pty_sys::sys_mkdir; +use t27os::pty_sys::sys_chdir; +use t27os::pty_sys::sys_getenv; +use t27os::pty_sys::sys_setenv; +use t27os::pty_sys::sys_strlen; +use t27os::pty_sys::sys_peek; +use t27os::pty_sys::sys_addr; +use t27os::pty_sys::sys_socket; +use t27os::pty_sys::sys_bind; +use t27os::pty_sys::sys_listen; +use t27os::pty_sys::sys_accept; +use t27os::pty_sys::sys_poll; +use t27os::pty_sys::sys_forkpty; +use t27os::pty_sys::sys_execv; +use t27os::pty_sys::sys_exit; +use t27os::pty_sys::sys_waitpid; +use t27os::pty_sys::sys_kill; +use t27os::pty_sys::sys_set_winsize; +use t27os::pty_sys::sys_signal; +use t27os::pty_sys::sys_gettimeofday; +use t27os::pty_sys::sys_random; +use t27os::pty_sys::sys_exe_path; +use t27os::pty_client::sockaddr_un; +use t27os::pty_client::client_connect; +use t27os::pty_client::client_list; +use t27os::pty_client::client_spawn; +use t27os::pty_client::client_keys; +use t27os::pty_client::client_read; +use t27os::pty_client::client_kill; +use t27os::pty_client::client_call; +use t27os::pty_client::AF_UNIX; +use t27os::pty_client::SOCK_STREAM; +use t27os::pty_client::SOCKADDR_LEN; +use t27os::pty_logic::PF; +use t27os::pty_logic::PF_USED; +use t27os::pty_logic::PF_LIVE; +use t27os::pty_logic::PF_PID; +use t27os::pty_logic::PF_FD; +use t27os::pty_logic::PF_AGENT; +use t27os::pty_logic::PF_SPACE; +use t27os::pty_logic::PF_SLOT; +use t27os::pty_logic::PF_STATUS; +use t27os::pty_logic::PF_TOTAL; +use t27os::pty_logic::PF_LAST; +use t27os::pty_logic::PF_SIDLEN; +use t27os::pty_logic::PF_CWDLEN; +use t27os::pty_logic::PF_BORN; +use t27os::pty_logic::PF_PLAN; +use t27os::pty_logic::resume_failed; +use t27os::pty_logic::hook_stale; +use t27os::pane_state::P_HOOK_STOP; +use t27os::pty_logic::G_LISTEN; +use t27os::pty_logic::G_HLEN; +use t27os::pty_logic::G_BINLEN; +use t27os::pty_logic::G_CFD; +use t27os::pty_logic::G_CLEN; +use t27os::pty_logic::G_WINSZ; +use t27os::pty_logic::ST_SIZE; +use t27os::pty_logic::MAX_CONNS; +use t27os::pty_logic::SID_CAP; +use t27os::pty_logic::CWD_CAP; +use t27os::pty_logic::IN_CAP; +use t27os::pty_logic::O_SID; +use t27os::pty_logic::O_CWD; +use t27os::pty_logic::O_IN; +use t27os::pty_logic::O_TMP1; +use t27os::pty_logic::O_TMP2; +use t27os::pty_logic::O_OUT; +use t27os::pty_logic::O_HOME; +use t27os::pty_logic::O_POLL; +use t27os::pty_logic::O_MISC; +use t27os::pty_logic::O_BIN; +use t27os::pty_logic::O_MODEL; +use t27os::pty_logic::MODEL_CAP; +use t27os::pty_logic::hook_model; +use t27os::pty_logic::spawn_cwd_len; +use t27os::pty_logic::O_RING; +use t27os::pty_logic::MEM_SIZE; +use t27os::pty_logic::TICK_MS; +use t27os::pty_logic::SIGHUP_RING; +use t27os::pty_logic::pane_alloc; +use t27os::pty_logic::tv_ms; +use t27os::pty_logic::put_pollfd; +use t27os::pty_logic::poll_revents; +use t27os::pty_logic::exit_event; +use t27os::pty_logic::hooked_status; +use t27os::pty_logic::list_flags; +use t27os::pty_logic::pane_command; +use t27os::pty_logic::argv_text; +use t27os::pty_logic::plist_text; +use t27os::pty_logic::c_path; + +// ---- the host loop: syscalls and bytes ---- + +fn env_text(name: []const u8, mem: []u8, at: usize) -> usize { + const p : u64 = sys_getenv(name); + if (p == 0) { return 0; } + var n : u64 = sys_strlen(p); + if (n > 200) { n = 200; } + sys_peek(mem[at .. at + 200], p, n); + return @as(usize, @intCast(n)); +} + +fn now_ms(mem: []u8) -> u64 { + sys_gettimeofday(mem[O_MISC .. O_MISC + 16], 0); + return tv_ms(mem[O_MISC .. O_MISC + 16]); +} + +fn home_slice(st: []const u64, mem: []const u8) -> []const u8 { + return mem[O_HOME .. O_HOME + @as(usize, @intCast(st[G_HLEN]))]; +} + +fn sid_of(st: []const u64, mem: []const u8, i: usize) -> []const u8 { + const at : usize = O_SID + i * SID_CAP; + return mem[at .. at + @as(usize, @intCast(st[i * PF + PF_SIDLEN]))]; +} + +fn cwd_of(st: []const u64, mem: []const u8, i: usize) -> []const u8 { + const at : usize = O_CWD + i * CWD_CAP; + return mem[at .. at + @as(usize, @intCast(st[i * PF + PF_CWDLEN]))]; +} + +fn model_of(mem: []const u8, i: usize) -> []const u8 { + const at : usize = O_MODEL + i * MODEL_CAP; + return mem[at + 1 .. at + 1 + @as(usize, mem[at])]; +} + +/// Set pane i's model from mem[at .. at + len] when model_ok() passes it; true when it changed. +fn set_model(mem: []u8, i: usize, at: usize, len: usize) -> bool { + if (len >= MODEL_CAP or !model_ok(mem[at .. at + len])) { return false; } + const mo : usize = O_MODEL + i * MODEL_CAP; + var same : bool = @as(usize, mem[mo]) == len; + var k : usize = 0; + while (k < len) { + if (mem[mo + 1 + k] != mem[at + k]) { same = false; } + mem[mo + 1 + k] = mem[at + k]; + k = k + 1; + } + mem[mo] = @as(u8, @intCast(len)); + return !same; +} + +/// The last model a claude pane's transcript names (session_store last_model), read in 32 KiB chunks +/// with a 128-byte overlap so a value across a chunk edge is still seen. Runs only from restore_all, +/// before any connection is accepted, so the connection buffers at O_IN are free. +fn model_from_transcript(st: []const u64, mem: []u8, i: usize) -> void { + if (st[i * PF + PF_AGENT] != 0 or st[i * PF + PF_SIDLEN] == 0) { return; } + const pn : usize = transcript_path(mem[O_TMP2 + 1536 .. O_TMP2 + 2048], home_slice(st, mem), cwd_of(st, mem, i), sid_of(st, mem, i)); + const fd : i32 = sys_open_ro(mem[O_TMP2 + 1536 .. O_TMP2 + 1536 + pn + 1], 0); + if (fd < 0) { return; } + var keep : usize = 0; + var ml : usize = 0; + var done : bool = false; + while (!done) { + const n : i64 = sys_read(fd, mem[O_IN + keep .. O_IN + keep + 32768], 32768); + if (n <= 0) { + done = true; + } else { + const total : usize = keep + @as(usize, @intCast(n)); + var f : [2]u64 = undefined; + if (last_model(mem[O_IN .. O_IN + total], f[0..2])) { + ml = @as(usize, @intCast(f[1])); + var k : usize = 0; + while (k < ml) { + mem[O_TMP2 + 2048 + k] = mem[O_IN + @as(usize, @intCast(f[0])) + k]; + k = k + 1; + } + } + keep = 128; + if (total < keep) { keep = total; } + var m : usize = 0; + while (m < keep) { + mem[O_IN + m] = mem[O_IN + total - keep + m]; + m = m + 1; + } + } + } + sys_close(fd); + if (ml > 0) { set_model(mem, i, O_TMP2 + 2048, ml); } +} + +fn ring_of(mem: []u8, i: usize) -> []u8 { + const at : usize = O_RING + i * 65536; + return mem[at .. at + 65536]; +} + +/// Write the record of pane i: temp file first, then rename over the real one. +fn save_pane(st: []const u64, mem: []u8, i: usize) -> void { + const base : usize = i * PF; + if (st[base + PF_USED] == 0) { return; } + var sid : []const u8 = sid_of(st, mem, i); + if (sid.len == 0) { sid = "-"; } + const rn : usize = put_record(mem[O_TMP2 + 512 .. O_TMP2 + 1536], @as(u32, @intCast(st[base + PF_SPACE])), @as(u32, @intCast(st[base + PF_AGENT])), @as(u32, @intCast(st[base + PF_SLOT])), ps_state(@as(u32, @intCast(st[base + PF_STATUS]))), sid, model_of(mem, i), cwd_of(st, mem, i)); + const home : []const u8 = home_slice(st, mem); + const tn : usize = record_path(mem[O_TMP2 .. O_TMP2 + 255], home, @as(u32, @intCast(i)), true); + mem[O_TMP2 + tn] = 0; + const fn_ : usize = record_path(mem[O_TMP2 + 256 .. O_TMP2 + 511], home, @as(u32, @intCast(i)), false); + mem[O_TMP2 + 256 + fn_] = 0; + const fd : i32 = sys_creat(mem[O_TMP2 .. O_TMP2 + tn + 1], 420); + if (fd < 0) { return; } + sys_write(fd, mem[O_TMP2 + 512 .. O_TMP2 + 512 + rn], @as(u64, rn)); + sys_close(fd); + sys_rename(mem[O_TMP2 .. O_TMP2 + tn + 1], mem[O_TMP2 + 256 .. O_TMP2 + 256 + fn_ + 1]); +} + +fn drop_record(st: []const u64, mem: []u8, i: usize) -> void { + const fn_ : usize = record_path(mem[O_TMP2 + 256 .. O_TMP2 + 511], home_slice(st, mem), @as(u32, @intCast(i)), false); + mem[O_TMP2 + 256 + fn_] = 0; + sys_unlink(mem[O_TMP2 + 256 .. O_TMP2 + 256 + fn_ + 1]); +} + +/// Start the process of pane i (fields already in st/mem) under plan R_RESUME or R_RELAUNCH. +fn launch(st: []u64, mem: []u8, i: usize, plan: u32) -> bool { + const base : usize = i * PF; + const agent : u32 = @as(u32, @intCast(st[base + PF_AGENT])); + if (agent == 0 and plan == R_RELAUNCH) { + sys_random(mem[O_MISC + 64 .. O_MISC + 80], 16); + const un : usize = uuid_text(mem[O_MISC + 64 .. O_MISC + 80], mem[O_SID + i * SID_CAP .. O_SID + i * SID_CAP + 40]); + st[base + PF_SIDLEN] = @as(u64, un); + } + const home : []const u8 = home_slice(st, mem); + const cn : usize = pane_command(agent, plan, sid_of(st, mem, i), model_of(mem, i), home, mem[O_TMP1 + 4096 .. O_TMP1 + 8000]); + var f : [4]u64 = undefined; + const tn : usize = argv_text(mem[O_TMP1 + 4096 .. O_TMP1 + 4096 + cn], mem[O_TMP1 .. O_TMP1 + 3000], f[0..4]); + const cw : usize = c_path(mem[O_TMP1 + 3000 .. O_TMP1 + 3300], cwd_of(st, mem, i)); + const idn : usize = put_dec(mem[O_TMP1 + 3300 .. O_TMP1 + 3340], 0, @as(u64, i)); + mem[O_TMP1 + 3300 + idn] = 0; + var argv : [4]u64 = undefined; + argv[0] = sys_addr(mem[O_TMP1 .. O_TMP1 + 8], mem[O_TMP1 .. O_TMP1 + 8], 0); + argv[1] = sys_addr(mem[O_TMP1 + @as(usize, @intCast(f[0])) .. O_TMP1 + 3000], mem[O_TMP1 .. O_TMP1 + 8], 0); + argv[2] = sys_addr(mem[O_TMP1 + @as(usize, @intCast(f[1])) .. O_TMP1 + 3000], mem[O_TMP1 .. O_TMP1 + 8], 0); + argv[3] = 0; + // the pane's last RESIZE, 80x24 when the window never sent one; no pixel size + const wsw : u64 = st[G_WINSZ + i / 2]; + const wsh : u32 = @as(u32, @intCast(i % 2)); + put_winsize(mem[O_MISC + 96 .. O_MISC + 104], ws_cols(wsw, wsh), ws_rows(wsw, wsh)); + var master : [2]u32 = undefined; + master[0] = 0; + master[1] = 0; + const pid : i32 = sys_forkpty(master, 0, 0, mem[O_MISC + 96 .. O_MISC + 104]); + if (pid == 0) { + // the child: keep only the pty on 0, 1 and 2, so a server that dies takes every master with it + var fd : i32 = 3; + while (fd < 256) { + sys_close(fd); + fd = fd + 1; + } + sys_chdir(mem[O_TMP1 + 3000 .. O_TMP1 + 3000 + cw + 1]); + sys_setenv("T27OS_PANE", mem[O_TMP1 + 3300 .. O_TMP1 + 3300 + idn + 1], 1); + sys_execv(mem[O_TMP1 .. O_TMP1 + 8], argv); + sys_exit(127); + return false; + } + if (pid < 0) { + st[base + PF_LIVE] = 0; + st[base + PF_STATUS] = @as(u64, pane_step(0, P_EXIT_FAIL)); + return false; + } + st[base + PF_LIVE] = 1; + st[base + PF_PID] = @as(u64, @intCast(pid)); + st[base + PF_FD] = @as(u64, master[0]); + st[base + PF_STATUS] = @as(u64, pane_step(0, P_SPAWNED)); + st[base + PF_TOTAL] = 0; + st[base + PF_LAST] = now_ms(mem); + st[base + PF_BORN] = st[base + PF_LAST]; + st[base + PF_PLAN] = @as(u64, plan); + return true; +} + +fn reap(st: []u64, mem: []u8, i: usize) -> void { + const base : usize = i * PF; + var ws : [2]u32 = undefined; + ws[0] = 0; + ws[1] = 0; + sys_waitpid(@as(i32, @intCast(st[base + PF_PID])), ws, 0); + sys_close(@as(i32, @intCast(st[base + PF_FD]))); + st[base + PF_LIVE] = 0; + if (resume_failed(@as(u32, @intCast(st[base + PF_PLAN])), st[base + PF_BORN], now_ms(mem), ws[0])) { + // nothing to resume: start the agent fresh in the same cwd + launch(st, mem, i, R_RELAUNCH); + save_pane(st, mem, i); + return; + } + // WIFEXITED with code 0 is a zero status word + st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), exit_event(ws[0]))); + save_pane(st, mem, i); +} + +fn kill_pane(st: []u64, mem: []u8, i: usize) -> void { + const base : usize = i * PF; + if (st[base + PF_LIVE] != 0) { + sys_kill(@as(i32, @intCast(st[base + PF_PID])), 9); + var ws : [2]u32 = undefined; + ws[0] = 0; + ws[1] = 0; + sys_waitpid(@as(i32, @intCast(st[base + PF_PID])), ws, 0); + sys_close(@as(i32, @intCast(st[base + PF_FD]))); + } + drop_record(st, mem, i); + st[base + PF_LIVE] = 0; + st[base + PF_USED] = 0; +} + +fn reply(fd: i32, mem: []u8, op: u8, pane: u8, plen: usize) -> void { + put_header(mem[O_OUT .. O_OUT + 8], op, pane, plen); + sys_write(fd, mem[O_OUT .. O_OUT + HEADER + plen], @as(u64, HEADER + plen)); +} + +fn handle_frame(st: []u64, mem: []u8, fd: i32, off: usize) -> void { + const op : u8 = mem[off]; + const pane : u32 = @as(u32, mem[off + 1]); + const plen : usize = @as(usize, get_u16(mem[off .. off + HEADER], 2)); + const p0 : usize = off + HEADER; + const base : usize = @as(usize, pane) * PF; + if (op == OP_LIST) { + var at : usize = O_OUT + HEADER; + var i : u32 = 0; + while (i < MAX_PANES) { + const b : usize = @as(usize, i) * PF; + if (st[b + PF_USED] != 0) { + const w : u32 = @as(u32, @intCast(st[b + PF_STATUS])); + at = put_list_entry(mem[O_OUT .. O_OUT + 128], at - O_OUT, i, @as(u32, @intCast(st[b + PF_AGENT])), shown_state(w), list_flags(st[b + PF_LIVE] != 0, w)) + O_OUT; + } + i = i + 1; + } + reply(fd, mem, RE_LISTING, 0, at - O_OUT - HEADER); + return; + } + if (op == OP_SPAWN) { + if (!spawn_ok(mem[p0 .. p0 + plen])) { reply(fd, mem, RE_ERR, 0, 0); return; } + const slot : u32 = pane_alloc(st); + const cl : usize = spawn_cwd_len(mem[p0 .. p0 + plen]); + if (slot >= MAX_PANES or cl > CWD_CAP - 1 or cl == 0 or mem[p0] >= AGENT_COUNT and mem[p0] != SHELL_CELL) { + reply(fd, mem, RE_ERR, 0, 0); + return; + } + const sb : usize = @as(usize, slot) * PF; + st[sb + PF_USED] = 1; + st[sb + PF_AGENT] = @as(u64, mem[p0]); + st[sb + PF_SPACE] = @as(u64, mem[p0 + 1]); + st[sb + PF_SLOT] = @as(u64, mem[p0 + 2]); + st[sb + PF_SIDLEN] = 0; + st[sb + PF_CWDLEN] = @as(u64, cl); + st[G_WINSZ + @as(usize, slot) / 2] = ws_pack(st[G_WINSZ + @as(usize, slot) / 2], slot % 2, 0, 0); + var k : usize = 0; + while (k < cl) { + mem[O_CWD + @as(usize, slot) * CWD_CAP + k] = mem[p0 + 3 + k]; + k = k + 1; + } + // a model after a TAB in the payload (T27OS_CTL=spawn with T27OS_MODEL) goes on the command line and into + // the record; without one the pane learns it from its SessionStart hook or, on restore, its transcript + mem[O_MODEL + @as(usize, slot) * MODEL_CAP] = 0; + if (3 + cl < plen) { set_model(mem, @as(usize, slot), p0 + 4 + cl, plen - 4 - cl); } + launch(st, mem, @as(usize, slot), R_RELAUNCH); + save_pane(st, mem, @as(usize, slot)); + reply(fd, mem, RE_OK, @as(u8, @intCast(slot)), 0); + return; + } + if (pane >= MAX_PANES or st[base + PF_USED] == 0) { reply(fd, mem, RE_ERR, 0, 0); return; } + if (op == OP_ATTACH) { + st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), P_SEEN)); + put_u64(mem[O_OUT .. O_OUT + 16], HEADER, st[base + PF_TOTAL]); + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 8); + return; + } + if (op == OP_DETACH) { reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); return; } + if (op == OP_RESIZE) { + if (resize_ok(mem[p0 .. p0 + plen]) == false) { reply(fd, mem, RE_ERR, 0, 0); return; } + const rc : u32 = get_u16(mem[p0 .. p0 + 4], 0); + const rr : u32 = get_u16(mem[p0 .. p0 + 4], 2); + const wi : usize = G_WINSZ + @as(usize, pane) / 2; + st[wi] = ws_pack(st[wi], pane % 2, rc, rr); + if (st[base + PF_LIVE] != 0) { + // the kernel signals the pty's foreground group on a size change; the explicit SIGWINCH to the + // pane's own group covers a child that has not taken the terminal yet + put_winsize(mem[O_MISC + 112 .. O_MISC + 120], rc, rr); + sys_set_winsize(@as(i32, @intCast(st[base + PF_FD])), TIOCSWINSZ, mem[O_MISC + 112 .. O_MISC + 120]); + sys_kill(0 - @as(i32, @intCast(st[base + PF_PID])), SIGWINCH); + } + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); + return; + } + if (op == OP_KEYS) { + if (st[base + PF_LIVE] == 0) { reply(fd, mem, RE_ERR, 0, 0); return; } + sys_write(@as(i32, @intCast(st[base + PF_FD])), mem[p0 .. p0 + plen], @as(u64, plen)); + st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), P_KEY)); + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); + return; + } + if (op == OP_READ) { + if (plen < 8) { reply(fd, mem, RE_ERR, 0, 0); return; } + var w : [2]u64 = undefined; + ring_window(st[base + PF_TOTAL], get_u64(mem[p0 .. p0 + 8], 0), w[0..2]); + const n : usize = data_chunk(w[1]); + ring_copy(ring_of(mem, @as(usize, pane)), w[0], n, mem[O_OUT + HEADER + 8 .. O_OUT + HEADER + 8 + 4096]); + put_u64(mem[O_OUT .. O_OUT + 16], HEADER, w[0] + @as(u64, n)); + reply(fd, mem, RE_DATA, @as(u8, @intCast(pane)), 8 + n); + return; + } + if (op == OP_KILL) { + kill_pane(st, mem, @as(usize, pane)); + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); + return; + } + if (op == OP_HOOK) { + var f : [2]u64 = undefined; + var changed : bool = false; + if (json_field(mem[p0 .. p0 + plen], "hook_event_name", f[0..2])) { + const e : usize = p0 + @as(usize, @intCast(f[0])); + const w : u32 = @as(u32, @intCast(st[base + PF_STATUS])); + const nw : u32 = hooked_status(w, mem[e .. e + @as(usize, @intCast(f[1]))]); + if (nw != w) { st[base + PF_STATUS] = @as(u64, nw); changed = true; } + } + if (st[base + PF_AGENT] == 0 and json_field(mem[p0 .. p0 + plen], "session_id", f[0..2])) { + const s0 : usize = p0 + @as(usize, @intCast(f[0])); + const sl : usize = @as(usize, @intCast(f[1])); + if (sid_ok(mem[s0 .. s0 + sl]) and sl <= SID_CAP) { + var k : usize = 0; + while (k < sl) { + mem[O_SID + @as(usize, pane) * SID_CAP + k] = mem[s0 + k]; + k = k + 1; + } + st[base + PF_SIDLEN] = @as(u64, sl); + changed = true; + } + } + if (st[base + PF_AGENT] == 0 and hook_model(mem[p0 .. p0 + plen], f[0..2])) { + if (set_model(mem, @as(usize, pane), p0 + @as(usize, @intCast(f[0])), @as(usize, @intCast(f[1])))) { changed = true; } + } + if (changed) { save_pane(st, mem, @as(usize, pane)); } + reply(fd, mem, RE_OK, @as(u8, @intCast(pane)), 0); + return; + } + reply(fd, mem, RE_ERR, 0, 0); +} + +/// Serve the whole inbuf of connection c; false when the connection is bad and must be dropped. +fn serve_conn(st: []u64, mem: []u8, c: usize) -> bool { + const off : usize = O_IN + c * IN_CAP; + const fd : i32 = @as(i32, @intCast(st[G_CFD + c] - 1)); + const room : usize = IN_CAP - @as(usize, @intCast(st[G_CLEN + c])); + if (room == 0) { return false; } + const n : i64 = sys_read(fd, mem[off + @as(usize, @intCast(st[G_CLEN + c])) .. off + IN_CAP], @as(u64, room)); + if (n <= 0) { return false; } + st[G_CLEN + c] = st[G_CLEN + c] + @as(u64, @intCast(n)); + var go : bool = true; + while (go) { + const have : usize = @as(usize, @intCast(st[G_CLEN + c])); + const need : usize = frame_need(mem[off .. off + have]); + if (need == 1) { return false; } + if (need == 0 or have < need) { + go = false; + } else { + handle_frame(st, mem, fd, off); + var k : usize = 0; + while (k + need < have) { + mem[off + k] = mem[off + need + k]; + k = k + 1; + } + st[G_CLEN + c] = @as(u64, have - need); + } + } + return true; +} + +fn serve_pane(st: []u64, mem: []u8, i: usize) -> void { + const base : usize = i * PF; + const n : i64 = sys_read(@as(i32, @intCast(st[base + PF_FD])), mem[O_MISC + 128 .. O_MISC + 128 + 4096], 4096); + if (n <= 0) { + reap(st, mem, i); + return; + } + st[base + PF_TOTAL] = ring_append(ring_of(mem, i), st[base + PF_TOTAL], mem[O_MISC + 128 .. O_MISC + 128 + 4096], @as(usize, @intCast(n))); + st[base + PF_LAST] = now_ms(mem); + st[base + PF_STATUS] = @as(u64, pane_step(@as(u32, @intCast(st[base + PF_STATUS])), P_OUTPUT)); +} + +fn quiet_tick(st: []u64, mem: []u8) -> void { + const now : u64 = now_ms(mem); + var i : usize = 0; + while (i < MAX_PANES) { + const base : usize = i * PF; + const w : u32 = @as(u32, @intCast(st[base + PF_STATUS])); + if (st[base + PF_LIVE] != 0 and quiet_applies(w) and is_quiet(now, st[base + PF_LAST])) { + st[base + PF_STATUS] = @as(u64, pane_step(w, P_QUIET)); + } + if (st[base + PF_LIVE] != 0 and hook_stale(w, now, st[base + PF_LAST])) { + st[base + PF_STATUS] = @as(u64, pane_step(w, P_HOOK_STOP)); + } + i = i + 1; + } +} + +/// Bring back every pane that has a record: resume or relaunch by restore_plan. +fn restore_all(st: []u64, mem: []u8) -> u32 { + var back : u32 = 0; + var i : u32 = 0; + while (i < MAX_PANES) { + const home : []const u8 = home_slice(st, mem); + const fn_ : usize = record_path(mem[O_TMP2 + 256 .. O_TMP2 + 511], home, i, false); + mem[O_TMP2 + 256 + fn_] = 0; + const fd : i32 = sys_open_ro(mem[O_TMP2 + 256 .. O_TMP2 + 256 + fn_ + 1], 0); + if (fd >= 0) { + const n : i64 = sys_read(fd, mem[O_TMP2 + 512 .. O_TMP2 + 1536], 1024); + sys_close(fd); + var f : [10]u64 = undefined; + if (n > 0 and parse_record(mem[O_TMP2 + 512 .. O_TMP2 + 512 + @as(usize, @intCast(n))], f[0..10])) { + const b : usize = @as(usize, i) * PF; + const sl : usize = @as(usize, @intCast(f[F_SID_LEN])); + const cl : usize = @as(usize, @intCast(f[F_CWD_LEN])); + if (sl <= SID_CAP and cl < CWD_CAP) { + var k : usize = 0; + while (k < sl) { + mem[O_SID + @as(usize, i) * SID_CAP + k] = mem[O_TMP2 + 512 + @as(usize, @intCast(f[F_SID_AT])) + k]; + k = k + 1; + } + k = 0; + while (k < cl) { + mem[O_CWD + @as(usize, i) * CWD_CAP + k] = mem[O_TMP2 + 512 + @as(usize, @intCast(f[F_CWD_AT])) + k]; + k = k + 1; + } + st[b + PF_USED] = 1; + st[b + PF_AGENT] = f[F_AGENT]; + st[b + PF_SPACE] = f[F_SPACE]; + st[b + PF_SLOT] = f[F_SLOT]; + st[b + PF_SIDLEN] = @as(u64, sl); + st[b + PF_CWDLEN] = @as(u64, cl); + // the record's model (a v1 record has none), then the newer one the transcript names + mem[O_MODEL + @as(usize, i) * MODEL_CAP] = 0; + set_model(mem, @as(usize, i), O_TMP2 + 512 + @as(usize, @intCast(f[F_MODEL_AT])), @as(usize, @intCast(f[F_MODEL_LEN]))); + model_from_transcript(st, mem, @as(usize, i)); + const sid : []const u8 = sid_of(st, mem, @as(usize, i)); + const sidv : []const u8 = sid; + var plan : u32 = restore_plan(@as(u32, @intCast(f[F_AGENT])), @as(u32, @intCast(f[F_STATE])), sidv); + if (plan == R_SKIP) { + st[b + PF_STATUS] = @as(u64, @intCast(f[F_STATE])); + } else { + if (launch(st, mem, @as(usize, i), plan)) { back = back + 1; } + save_pane(st, mem, @as(usize, i)); + } + } + } + } + i = i + 1; + } + return back; +} + +fn say(msg: []const u8) -> void { + sys_write(1, msg, @as(u64, msg.len)); +} + +/// Make ~/.t27os, ~/.t27os/sessions and the hooks settings file. +fn prepare_dirs(st: []u64, mem: []u8) -> void { + const home : []const u8 = home_slice(st, mem); + var n : usize = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], 0, home); + n = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], n, "/.t27os"); + mem[O_TMP2 + n] = 0; + sys_mkdir(mem[O_TMP2 .. O_TMP2 + n + 1], 493); + n = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], n, "/sessions"); + mem[O_TMP2 + n] = 0; + sys_mkdir(mem[O_TMP2 .. O_TMP2 + n + 1], 493); + var f : [2]u64 = undefined; + if (hooks_json(mem[O_TMP2 + 512 .. O_TMP2 + 1536], mem[O_BIN .. O_BIN + @as(usize, @intCast(st[G_BINLEN]))], f[0..2])) { + var p : usize = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], 0, home); + p = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], p, "/.t27os/hooks.json"); + mem[O_TMP2 + p] = 0; + const fd : i32 = sys_creat(mem[O_TMP2 .. O_TMP2 + p + 1], 420); + if (fd >= 0) { + sys_write(fd, mem[O_TMP2 + 512 .. O_TMP2 + 512 + @as(usize, @intCast(f[0]))], f[0]); + sys_close(fd); + } + } +} + +fn open_socket(st: []u64, mem: []u8) -> bool { + const pn : usize = socket_path(mem[O_TMP2 .. O_TMP2 + 128], home_slice(st, mem)); + if (pn == 0) { return false; } + mem[O_TMP2 + pn] = 0; + sys_unlink(mem[O_TMP2 .. O_TMP2 + pn + 1]); + sockaddr_un(mem[O_TMP2 + 256 .. O_TMP2 + 362], mem[O_TMP2 .. O_TMP2 + pn]); + const fd : i32 = sys_socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { return false; } + if (sys_bind(fd, mem[O_TMP2 + 256 .. O_TMP2 + 362], SOCKADDR_LEN) < 0) { return false; } + if (sys_listen(fd, 8) < 0) { return false; } + st[G_LISTEN] = @as(u64, @intCast(fd)); + return true; +} + +fn serve_loop(st: []u64, mem: []u8) -> void { + var kind : [64]u8 = undefined; + var idx : [64]u8 = undefined; + while (true) { + var k : usize = 0; + put_pollfd(mem[O_POLL .. O_POLL + 512], k, @as(u32, @intCast(st[G_LISTEN]))); + kind[k] = 0; + idx[k] = 0; + k = k + 1; + var c : usize = 0; + while (c < MAX_CONNS) { + if (st[G_CFD + c] != 0) { + put_pollfd(mem[O_POLL .. O_POLL + 512], k, @as(u32, @intCast(st[G_CFD + c] - 1))); + kind[k] = 1; + idx[k] = @as(u8, @intCast(c)); + k = k + 1; + } + c = c + 1; + } + var i : usize = 0; + while (i < MAX_PANES) { + if (st[i * PF + PF_LIVE] != 0) { + put_pollfd(mem[O_POLL .. O_POLL + 512], k, @as(u32, @intCast(st[i * PF + PF_FD]))); + kind[k] = 2; + idx[k] = @as(u8, @intCast(i)); + k = k + 1; + } + i = i + 1; + } + const r : i32 = sys_poll(mem[O_POLL .. O_POLL + 512], @as(u64, k), TICK_MS); + if (r > 0) { + var e : usize = 0; + while (e < k) { + if (poll_revents(mem[O_POLL .. O_POLL + 512], e) != 0) { + if (kind[e] == 0) { + const nfd : i32 = sys_accept(@as(i32, @intCast(st[G_LISTEN])), 0, 0); + if (nfd >= 0) { + var slot : usize = MAX_CONNS; + var q : usize = 0; + while (q < MAX_CONNS) { + if (st[G_CFD + q] == 0 and slot == MAX_CONNS) { slot = q; } + q = q + 1; + } + if (slot == MAX_CONNS) { + sys_close(nfd); + } else { + st[G_CFD + slot] = @as(u64, @intCast(nfd)) + 1; + st[G_CLEN + slot] = 0; + } + } + } else if (kind[e] == 1) { + const cc : usize = @as(usize, idx[e]); + if (!serve_conn(st, mem, cc)) { + sys_close(@as(i32, @intCast(st[G_CFD + cc] - 1))); + st[G_CFD + cc] = 0; + st[G_CLEN + cc] = 0; + } + } else { + serve_pane(st, mem, @as(usize, idx[e])); + } + } + e = e + 1; + } + } + quiet_tick(st, mem); + } +} + +fn run_server(st: []u64, mem: []u8) -> void { + prepare_dirs(st, mem); + if (!open_socket(st, mem)) { + say("t27os-server: cannot open the socket\n"); + return; + } + const back : u32 = restore_all(st, mem); + say("t27os-server: listening, restored "); + var nb : [8]u8 = undefined; + const nn : usize = put_dec(nb[0..8], 0, @as(u64, back)); + nb[nn] = 10; + say(nb[0 .. nn + 1]); + serve_loop(st, mem); +} + +/// T27OS_HOOK=1: forward the hook payload on stdin to the pane named by T27OS_PANE. +fn run_hook(st: []u64, mem: []u8) -> void { + var pane : usize = 0; + const pl : usize = env_text("T27OS_PANE", mem, O_MISC); + var i : usize = 0; + while (i < pl) { + pane = pane * 10 + @as(usize, mem[O_MISC + i]) - 48; + i = i + 1; + } + var have : usize = 0; + var done : bool = false; + while (!done and have < 3000) { + const n : i64 = sys_read(0, mem[O_TMP1 + have .. O_TMP1 + 3000], @as(u64, 3000 - have)); + if (n <= 0) { done = true; } else { have = have + @as(usize, @intCast(n)); } + } + const fd : i32 = client_connect(home_slice(st, mem)); + if (fd < 0) { return; } + client_call(fd, OP_HOOK, @as(u8, @intCast(pane)), mem[O_TMP1 .. O_TMP1 + have], mem[O_OUT .. O_OUT + 4200]); + sys_close(fd); +} + +fn print_dec(v: u64, mem: []u8) -> void { + const n : usize = put_dec(mem[O_MISC + 200 .. O_MISC + 230], 0, v); + sys_write(1, mem[O_MISC + 200 .. O_MISC + 200 + n], @as(u64, n)); +} + +/// T27OS_CTL=: list | spawn | kill | read | keys against a running server. +fn run_ctl(st: []u64, mem: []u8, verb: u8) -> void { + const fd : i32 = client_connect(home_slice(st, mem)); + if (fd < 0) { say("no server\n"); return; } + const out : []u8 = mem[O_OUT .. O_OUT + 4200]; + var pane : u32 = 0; + const pl : usize = env_text("T27OS_PANE", mem, O_MISC + 256); + var i : usize = 0; + while (i < pl) { + pane = pane * 10 + @as(u32, mem[O_MISC + 256 + i]) - 48; + i = i + 1; + } + if (verb == 108) { + const n : usize = client_list(fd, out); + var at : usize = HEADER; + while (at + 4 <= n) { + say("pane "); + print_dec(@as(u64, out[at]), mem); + say(" agent "); + print_dec(@as(u64, out[at + 1]), mem); + say(" state "); + print_dec(@as(u64, out[at + 2]), mem); + say(" flags "); + print_dec(@as(u64, out[at + 3]), mem); + say("\n"); + at = at + 4; + } + } else if (verb == 115) { + const al : usize = env_text("T27OS_AGENT", mem, O_MISC + 300); + var agent : u32 = 0; + var a : usize = 0; + while (a < al) { + agent = agent * 10 + @as(u32, mem[O_MISC + 300 + a]) - 48; + a = a + 1; + } + var cl : usize = env_text("T27OS_CWD", mem, O_TMP1); + const ml : usize = env_text("T27OS_MODEL", mem, O_TMP1 + cl + 1); + if (ml > 0) { + mem[O_TMP1 + cl] = 9; + cl = cl + 1 + ml; + } + client_spawn(fd, agent, 0, 0, mem[O_TMP1 .. O_TMP1 + cl], out); + say("spawned pane "); + print_dec(@as(u64, out[1]), mem); + say("\n"); + } else if (verb == 107) { + client_kill(fd, pane, out); + say("killed\n"); + } else if (verb == 114) { + const n : usize = client_read(fd, pane, 0, out); + if (n > HEADER + 8) { sys_write(1, out[HEADER + 8 .. n], @as(u64, n - HEADER - 8)); } + } else if (verb == 119) { + const kl : usize = env_text("T27OS_KEYS", mem, O_TMP1); + mem[O_TMP1 + kl] = 13; + client_keys(fd, pane, mem[O_TMP1 .. O_TMP1 + kl + 1], out); + say("sent\n"); + } + sys_close(fd); +} + +/// T27OS_INSTALL=1: write the launchd agent plist for this binary. +fn run_install(st: []u64, mem: []u8) -> void { + const home : []const u8 = home_slice(st, mem); + var n : usize = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], 0, home); + n = put_bytes(mem[O_TMP2 .. O_TMP2 + 255], n, "/Library/LaunchAgents/ai.t27.os-server.plist"); + mem[O_TMP2 + n] = 0; + const pl : usize = env_text("PATH", mem, O_TMP1); + const ll : usize = env_text("LANG", mem, O_TMP1 + 4096); + const pn : usize = plist_text(mem[O_TMP2 + 512 .. O_TMP2 + 8192], mem[O_BIN .. O_BIN + @as(usize, @intCast(st[G_BINLEN]))], home, mem[O_TMP1 .. O_TMP1 + pl], mem[O_TMP1 + 4096 .. O_TMP1 + 4096 + ll]); + const fd : i32 = sys_creat(mem[O_TMP2 .. O_TMP2 + n + 1], 420); + if (fd < 0) { say("cannot write the plist\n"); return; } + sys_write(fd, mem[O_TMP2 + 512 .. O_TMP2 + 512 + pn], @as(u64, pn)); + sys_close(fd); + say("wrote the launchd plist; launchctl bootstrap gui/$UID it to start\n"); +} + +pub fn main() -> void { + var st : [256]u64 = undefined; + var mem : [1146880]u8 = undefined; + var z : usize = 0; + while (z < ST_SIZE) { + st[z] = 0; + z = z + 1; + } + sys_signal(13, 1); + st[G_HLEN] = @as(u64, env_text("HOME", mem[0 .. MEM_SIZE], O_HOME)); + var bsz : [2]u32 = undefined; + bsz[0] = 250; + bsz[1] = 0; + if (sys_exe_path(mem[O_BIN .. O_BIN + 256], bsz) == 0) { + st[G_BINLEN] = sys_strlen(sys_addr(mem[O_BIN .. O_BIN + 256], mem[O_BIN .. O_BIN + 256], 0)); + } + if (st[G_HLEN] == 0) { say("t27os-server: HOME is not set\n"); return; } + if (sys_getenv("T27OS_HOOK") != 0) { run_hook(st[0..ST_SIZE], mem[0..MEM_SIZE]); return; } + if (sys_getenv("T27OS_INSTALL") != 0) { run_install(st[0..ST_SIZE], mem[0..MEM_SIZE]); return; } + const cl : usize = env_text("T27OS_CTL", mem[0..MEM_SIZE], O_MISC + 400); + if (cl > 0) { run_ctl(st[0..ST_SIZE], mem[0..MEM_SIZE], mem[O_MISC + 400]); return; } + run_server(st[0..ST_SIZE], mem[0..MEM_SIZE]); +} + + +// ---- the decisions are tested in pty_logic.t27; this one checks the arena layout the loop relies on ---- + +test the_arena_regions_do_not_overlap { + assert O_SID + 16 * SID_CAP <= O_CWD; + assert O_CWD + 16 * CWD_CAP <= O_IN; + assert O_IN + MAX_CONNS * IN_CAP <= O_TMP1; + assert O_TMP1 + 8192 <= O_TMP2; + assert O_TMP2 + 8192 <= O_OUT; + assert O_OUT + 4200 <= O_HOME; + assert O_HOME + 256 <= O_POLL; + assert O_POLL + 512 <= O_MISC; + assert O_MISC + 328 <= O_BIN; + assert O_BIN + 256 <= O_RING; + assert O_RING + 16 * 65536 <= MEM_SIZE; +} diff --git a/specs/t27os/pty_sys.t27 b/specs/t27os/pty_sys.t27 new file mode 100644 index 0000000000..3c61e4ea49 --- /dev/null +++ b/specs/t27os/pty_sys.t27 @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// T27 OS pty_sys: the libSystem calls the PTY server and its client make, as t27b @extern imports +// (gHashTag/t27#8946). One home for them, so the server and the client share a declaration instead of each +// keeping a copy. Nothing here decides anything; every decision is a fn in pty_proto, session_store, +// pane_state or pty_server, tested there. +// +// Calling rules these signatures follow (arm64 Darwin): a str or slice argument is its bytes' address, an +// aggregate array is its own address, a scalar goes by value, an `int` result is read as i32 and an +// ssize_t result as i64. Variadic C calls (open with O_CREAT, fcntl, ioctl) are not imported: their +// variadic arguments travel on the stack, which an @extern call does not fill (t27#10902). creat(2) makes +// the files, poll(2) replaces O_NONBLOCK, and a size change calls __ioctl, the fixed-argument syscall stub +// libsystem_kernel exports and the variadic ioctl() wraps: (int fd, unsigned long req, void *arg). +// +// Refs gHashTag/t27#10782. +module T27osPtySys; + +pub fn sys_write(fd: i32, b: []const u8, n: u64) -> i64 { return @extern("write"); } +pub fn sys_read(fd: i32, b: []u8, n: u64) -> i64 { return @extern("read"); } +pub fn sys_close(fd: i32) -> i32 { return @extern("close"); } +pub fn sys_open_ro(path: []const u8, flags: i32) -> i32 { return @extern("open"); } +pub fn sys_creat(path: []const u8, mode: u32) -> i32 { return @extern("creat"); } +pub fn sys_rename(from: []const u8, to: []const u8) -> i32 { return @extern("rename"); } +pub fn sys_unlink(path: []const u8) -> i32 { return @extern("unlink"); } +pub fn sys_mkdir(path: []const u8, mode: u32) -> i32 { return @extern("mkdir"); } +pub fn sys_chdir(path: []const u8) -> i32 { return @extern("chdir"); } +pub fn sys_getenv(name: []const u8) -> u64 { return @extern("getenv"); } +pub fn sys_setenv(name: []const u8, val: []const u8, overwrite: i32) -> i32 { return @extern("setenv"); } +pub fn sys_strlen(p: u64) -> u64 { return @extern("strlen"); } +/// memmove with a raw source address: copy n bytes from memory at `src` into dst. +pub fn sys_peek(dst: []u8, src: u64, n: u64) -> u64 { return @extern("memmove"); } +/// memmove with n = 0 hands back dst's address, which is how a byte buffer becomes an argv entry. +pub fn sys_addr(dst: []u8, src: []const u8, n: u64) -> u64 { return @extern("memmove"); } +pub fn sys_socket(domain: i32, kind: i32, proto: i32) -> i32 { return @extern("socket"); } +pub fn sys_bind(fd: i32, addr: []const u8, len: u32) -> i32 { return @extern("bind"); } +pub fn sys_connect(fd: i32, addr: []const u8, len: u32) -> i32 { return @extern("connect"); } +pub fn sys_listen(fd: i32, backlog: i32) -> i32 { return @extern("listen"); } +pub fn sys_accept(fd: i32, addr: u64, len: u64) -> i32 { return @extern("accept"); } +pub fn sys_poll(fds: []u8, n: u64, ms: i32) -> i32 { return @extern("poll"); } +pub fn sys_forkpty(master: [2]u32, name: u64, term: u64, ws: []const u8) -> i32 { return @extern("forkpty"); } +pub fn sys_execv(path: []const u8, argv: [4]u64) -> i32 { return @extern("execv"); } +pub fn sys_exit(code: i32) -> void { @extern("_exit"); } +pub fn sys_waitpid(pid: i32, status: [2]u32, opts: i32) -> i32 { return @extern("waitpid"); } +pub fn sys_kill(pid: i32, sig: i32) -> i32 { return @extern("kill"); } +/// ioctl(fd, TIOCSWINSZ, &winsize) without the variadic wrapper; ws is the 8-byte struct winsize. +pub fn sys_set_winsize(fd: i32, req: u64, ws: []const u8) -> i32 { return @extern("__ioctl"); } +pub fn sys_signal(sig: i32, handler: u64) -> u64 { return @extern("signal"); } +pub fn sys_gettimeofday(tv: []u8, tz: u64) -> i32 { return @extern("gettimeofday"); } +pub fn sys_random(b: []u8, n: u64) -> void { @extern("arc4random_buf"); } +pub fn sys_exe_path(b: []u8, size: [2]u32) -> i32 { return @extern("_NSGetExecutablePath"); } + +test the_imports_are_declared { + var b : [4]u8 = undefined; + b[0] = 1; + assert b[0] == 1; +} diff --git a/specs/t27os/queen.t27 b/specs/t27os/queen.t27 new file mode 100644 index 0000000000..7ec931fb0f --- /dev/null +++ b/specs/t27os/queen.t27 @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/queen.t27 -- the Queen panel of T27 OS: what the user looked at and pressed, what the Queen +; suggests from it, and GOD MODE. +; Owner, 2026-10-19: the Queen must open a side panel anywhere; she is the orchestrator over the agents +; and sees the context -- where in the interface the user is looking and which buttons they pressed -- +; to help the user steer the agents; fully under human control. Then: she should move tasks forward +; proactively, and in GOD MODE do it on her own. +; The model, all of it plain integers so the hot reload keeps it: +; EVENTS a ring of the last RING user actions, one u64 each: the view, the control, its argument +; (an agent or a space) and the time in seconds. The panel shows it as RECENT. +; CONTEXT the view, the space and the selected chat, plus the ring -- what the panel shows under +; LOOKING AT, and what the Queen reads. +; SUGGESTION one action the Queen proposes from the context. By default nothing runs until the user +; presses Confirm; Dismiss hides it until the user does something else. +; GOD MODE a visible switch in the panel. While it is on, the Queen runs her suggestion herself once +; the user has been still for GOD_WAIT seconds, and logs it in RECENT as her own action. +; Cmd+J or Escape stops it, as does the switch itself or closing the panel: she only acts +; while the panel, and so the log, is on screen. +; Black and white only (FR-006): GOD MODE is a box, empty when off and filled when on, with the words +; "GOD MODE OFF" / "GOD MODE ON" beside it -- shape and text, never a colour. +; The one exception to FR-006, and only as the GOD MODE signal (owner, 2026-10-19: the edge glow +; lights up in god mode; later the same day: the colour of a blue gas flame on a kitchen stove): +; while GOD MODE is on a blue flame (FLAME_*, defined here and nowhere else) lies along the window +; edges: a bright cyan cone at the edge, a vivid blue body, a deep violet-blue base, softly blurred, +; breathing and flickering on the frame count, fading in and out over 1 / GLOW_STEP frames. +; In the normal human-confirm mode the edges are pure black: no glow, and the backdrop is opaque so +; the desktop wallpaper does not show through as a warm wash. +; Not here yet (no server behind them): a real Queen model reading the context, tasks on a board, and +; prompts sent into agent processes. The only actions are the ones the app can do today: open a chat, +; select a chat. + +module T27osQueen; + +use t27os::agents::AGENT_COUNT; +use t27os::windows::SESSION_NONE; +use t27os::windows::NEW_CHAT_DEFAULT; +use t27os::windows::session_count; +use t27os::windows::session_agent; +use t27os::windows::add_session; +use t27os::windows::session_at_row; +use t27os::windows::sessions_in; + +/// The ring holds the last RING events. +pub const RING : u32 = 8; +pub const RING_NONE : u32 = 4294967295; + +/// Views: a world index (0 .. 14) when a screen is open, VIEW_HOME on the petal menu. +pub const VIEW_HOME : u32 = 15; + +/// Controls, in CONTROL_NAMES order. +pub const C_CELL : u32 = 0; +pub const C_NEW_CHAT : u32 = 1; +pub const C_CLOSE_CHAT : u32 = 2; +pub const C_QUEEN_ROW : u32 = 3; +pub const C_SPACE_ROW : u32 = 4; +pub const C_AGENT_ROW : u32 = 5; +pub const C_SIDEBAR : u32 = 6; +pub const C_DASHBOARD : u32 = 7; +pub const C_QUEEN_KEY : u32 = 8; +pub const C_CONFIRM : u32 = 9; +pub const C_DISMISS : u32 = 10; +pub const C_GOD_ON : u32 = 11; +pub const C_GOD_OFF : u32 = 12; +pub const C_QUEEN_OPENED : u32 = 13; +pub const C_QUEEN_PICKED : u32 = 14; +pub const C_HOME : u32 = 15; +pub const C_WORLD : u32 = 16; +pub const C_NEW_SPACE : u32 = 17; +pub const C_CLOSE_SPACE : u32 = 18; +pub const C_REPO_LINK : u32 = 19; +pub const C_COUNT : u32 = 20; +pub const C_NONE : u32 = 4294967295; +pub const CONTROL_NAMES : str = "tapped a cell,+ new chat,closed a chat,Queen row,picked a space,picked a chat,sidebar,dashboard,Queen panel,confirmed,dismissed,GOD MODE on,GOD MODE off,she opened,she picked,went home,opened a screen,+ new space,closed a space,opened the repo"; + +/// What an event's argument names. +pub const ARG_NONE : u32 = 0; +pub const ARG_AGENT : u32 = 1; +pub const ARG_SPACE : u32 = 2; + +/// Suggestions, in SUGGEST_WORDS order. +pub const A_NONE : u32 = 0; +pub const A_OPEN : u32 = 1; +pub const A_SELECT : u32 = 2; +pub const SUGGEST_WORDS : str = "Nothing to do here,Open a Claude Code chat in this space,Open the first chat of this space"; + +/// The age of an event, in AGE_WORDS order. +pub const AGE_WORDS : str = "just now,seconds ago,a minute ago,minutes ago,earlier"; + +/// Seconds of stillness before GOD MODE acts, and between two of her actions. +pub const GOD_WAIT : u64 = 3; + +/// Panel geometry: a column QP_W wide on the right, from the top of the workspace (points). +pub const QP_W : f64 = 300.0; +pub const QP_PAD : f64 = 16.0; +pub const QP_GOD_Y : f64 = 40.0; +pub const QP_GOD_H : f64 = 44.0; +pub const QP_CTX_Y : f64 = 104.0; +pub const QP_SUG_Y : f64 = 176.0; +pub const QP_BTN_Y : f64 = 208.0; +pub const QP_BTN_W : f64 = 96.0; +pub const QP_BTN_H : f64 = 26.0; +pub const QP_BTN_GAP : f64 = 8.0; +pub const QP_RECENT_Y : f64 = 262.0; +pub const QP_LINE : f64 = 20.0; +pub const QP_CLOSE : f64 = 16.0; + +// ---- GOD MODE edge glow (the FR-006 exception) ---- + +/// A blue gas flame on a kitchen stove (owner, 2026-10-19), the only colour on the screen, and only +/// while GOD MODE is on. Three stops from the window edge inward: the bright cyan inner cone at the +/// edge, a vivid blue body, and a deep violet-blue base where the flame fades into the black. +pub const FLAME_CORE_R : f64 = 0.56; +pub const FLAME_CORE_G : f64 = 0.86; +pub const FLAME_CORE_B : f64 = 1.0; +pub const FLAME_MID_R : f64 = 0.16; +pub const FLAME_MID_G : f64 = 0.42; +pub const FLAME_MID_B : f64 = 1.0; +pub const FLAME_BASE_R : f64 = 0.22; +pub const FLAME_BASE_G : f64 = 0.10; +pub const FLAME_BASE_B : f64 = 0.80; +/// The cone ends and the body begins this far (0..1) across the glow. +pub const FLAME_CONE : f64 = 0.22; +/// The glow is GLOW_BANDS strips across GLOW_W pt from each edge, brightest at the edge: many thin +/// strips with a smooth falloff read as a blur rather than a line. +pub const GLOW_W : f64 = 44.0; +pub const GLOW_BANDS : u32 = 30; +pub const GLOW_PEAK : f64 = 0.62; +/// The bright hairline of the cone at the very edge, GLOW_CORE_W pt wide, blurred GLOW_BLUR pt. +pub const GLOW_CORE_W : f64 = 1.5; +pub const GLOW_BLUR : f64 = 22.0; +pub const GLOW_CORE_A : f64 = 0.85; +/// Breathing: the whole flame dims by up to BREATH_AMP over BREATH_FRAMES (3 s at 30 fps). +pub const BREATH_FRAMES : u32 = 90; +pub const BREATH_AMP : f64 = 0.16; +/// Flicker: tongues travel along the bands, a strip LICK_SHIFT frames behind the one outside it, +/// LICK_FRAMES per cycle; the outer bands (the tips) flicker by up to LICK_AMP, the cone hardly at all. +pub const LICK_FRAMES : u32 = 19; +pub const LICK_SHIFT : u32 = 3; +pub const LICK_AMP : f64 = 0.30; +/// The level moves this far toward on or off each frame: about a third of a second at 30 fps. +pub const GLOW_STEP : f64 = 0.1; + +/// The glow is on exactly while GOD MODE is on; the human-confirm mode has black edges. +pub fn glow_on(god: bool) -> bool { + return god; +} + +/// The glow level after one frame: up toward 1 while it is on, down toward 0 while it is off. +pub fn glow_level(g: f64, on: bool) -> f64 { + if (on) { + if (g + GLOW_STEP >= 1.0) { return 1.0; } + return g + GLOW_STEP; + } + if (g - GLOW_STEP <= 0.0) { return 0.0; } + return g - GLOW_STEP; +} + +/// How far strip `band` lies across the glow: 0 at the edge, approaching 1 at the inner side. +pub fn band_t(band: u32) -> f64 { + return @as(f64, @floatFromInt(band)) / @as(f64, @floatFromInt(GLOW_BANDS)); +} + +/// A smooth wave in 0..1 with period `p` frames: a triangle eased with smoothstep, 0 at n = 0. +pub fn flame_wave(n: u32, p: u32) -> f64 { + const m : f64 = @as(f64, @floatFromInt(n % p)); + var x : f64 = 2.0 * m / @as(f64, @floatFromInt(p)); + if (x > 1.0) { x = 2.0 - x; } + return x * x * (3.0 - 2.0 * x); +} + +/// The flame's brightness (1 at rest) for strip `band` on frame `frame`: slow breathing on the +/// whole flame, and tongues that flicker more toward the outer tips. +pub fn flicker(frame: u32, band: u32) -> f64 { + const breath : f64 = flame_wave(frame, BREATH_FRAMES); + const lick : f64 = flame_wave(frame + band * LICK_SHIFT, LICK_FRAMES); + return 1.0 - BREATH_AMP * breath - LICK_AMP * band_t(band) * lick; +} + +/// Channel `k` (0 red, 1 green, 2 blue) of strip `band`: cone to body over 0..FLAME_CONE, body to +/// base over FLAME_CONE..1. +pub fn flame_rgb(band: u32, k: u32) -> f64 { + const t : f64 = band_t(band); + var a : f64 = FLAME_CORE_R; + var b : f64 = FLAME_MID_R; + var u : f64 = t / FLAME_CONE; + if (t >= FLAME_CONE) { + a = FLAME_MID_R; + b = FLAME_BASE_R; + u = (t - FLAME_CONE) / (1.0 - FLAME_CONE); + if (k == 1) { a = FLAME_MID_G; b = FLAME_BASE_G; } + if (k == 2) { a = FLAME_MID_B; b = FLAME_BASE_B; } + } else { + if (k == 1) { a = FLAME_CORE_G; b = FLAME_MID_G; } + if (k == 2) { a = FLAME_CORE_B; b = FLAME_MID_B; } + } + return a + (b - a) * u; +} + +/// The alpha of strip `band` (0 at the edge) at level `g` on frame `frame`: a smooth cubic falloff +/// times the flicker; nothing at level 0 or past the last strip. +pub fn glow_alpha(g: f64, band: u32, frame: u32) -> f64 { + if (band >= GLOW_BANDS) { return 0.0; } + const f : f64 = 1.0 - band_t(band); + return GLOW_PEAK * g * f * f * f * flicker(frame, band); +} + +/// The alpha of the blurred cone hairline at level `g` on frame `frame`: it breathes, it does not lick. +pub fn core_alpha(g: f64, frame: u32) -> f64 { + return GLOW_CORE_A * g * flicker(frame, 0); +} + +// ---- events ---- + +fn pow2(k: u32) -> u64 { + var p : u64 = 1; + var i : u32 = 0; + while (i < k) { + p = p * 2; + i = i + 1; + } + return p; +} + +/// One event: 1 + view + 16 control + 1024 argument + 2^18 seconds; 0 is an empty slot. +pub fn ev_pack(view: u32, control: u32, arg: u32, sec: u64) -> u64 { + return 1 + @as(u64, view % 16) + 16 * @as(u64, control % 64) + 1024 * @as(u64, arg % 256) + pow2(18) * sec; +} + +pub fn ev_view(e: u64) -> u32 { + if (e == 0) { return VIEW_HOME; } + return @as(u32, @intCast((e - 1) % 16)); +} + +pub fn ev_control(e: u64) -> u32 { + if (e == 0) { return C_NONE; } + return @as(u32, @intCast((e - 1) / 16 % 64)); +} + +pub fn ev_arg(e: u64) -> u32 { + if (e == 0) { return 0; } + return @as(u32, @intCast((e - 1) / 1024 % 256)); +} + +pub fn ev_sec(e: u64) -> u64 { + if (e == 0) { return 0; } + return (e - 1) / pow2(18); +} + +/// What the argument of a control names: the agent for chat controls, the space for a space row. +pub fn arg_kind(control: u32) -> u32 { + if (control == C_CELL or control == C_NEW_CHAT or control == C_CLOSE_CHAT or control == C_AGENT_ROW) { return ARG_AGENT; } + if (control == C_QUEEN_OPENED or control == C_QUEEN_PICKED) { return ARG_AGENT; } + if (control == C_SPACE_ROW or control == C_NEW_SPACE or control == C_CLOSE_SPACE) { return ARG_SPACE; } + return ARG_NONE; +} + +/// The Queen's own actions, so the log can tell them from the user's. +pub fn by_queen(control: u32) -> bool { + return control == C_QUEEN_OPENED or control == C_QUEEN_PICKED; +} + +/// The view a frame is on: the open world, or home. +pub fn view_of(open: bool, shown: u32) -> u32 { + if (open and shown < VIEW_HOME) { return shown; } + return VIEW_HOME; +} + +/// Events held after `n` pushes. +pub fn ring_len(n: u32) -> u32 { + if (n < RING) { return n; } + return RING; +} + +/// The slot to write the next event into after `n` pushes. +pub fn ring_next(n: u32) -> u32 { + return n % RING; +} + +/// The slot of the `back`-th most recent event (0 = the newest), or RING_NONE past the oldest. +pub fn ring_at(n: u32, back: u32) -> u32 { + if (back >= ring_len(n)) { return RING_NONE; } + return (n - 1 - back) % RING; +} + +/// How old an event is, as an AGE_WORDS index. +pub fn age_word(now: u64, then: u64) -> u32 { + if (then >= now) { return 0; } + const d : u64 = now - then; + if (d < 5) { return 0; } + if (d < 60) { return 1; } + if (d < 120) { return 2; } + if (d < 3600) { return 3; } + return 4; +} + +// ---- suggestion and GOD MODE ---- + +/// The Queen's one suggestion from the context: open a chat in an empty space, or select one when +/// the space has chats and none is selected. +pub fn suggest(rows: u32, selected: bool) -> u32 { + if (rows == 0) { return A_OPEN; } + if (selected == false) { return A_SELECT; } + return A_NONE; +} + +/// A dismissed suggestion stays hidden until the user does something else. +pub fn shown_suggestion(s: u32, dismissed: u32) -> u32 { + if (s == dismissed) { return A_NONE; } + return s; +} + +/// The control that logs a Queen action. +pub fn queen_control(s: u32) -> u32 { + if (s == A_OPEN) { return C_QUEEN_OPENED; } + if (s == A_SELECT) { return C_QUEEN_PICKED; } + return C_NONE; +} + +/// True when GOD MODE should act now: on, the panel open, something to do, and the user and the +/// Queen herself both still for GOD_WAIT seconds. +pub fn god_due(god: bool, open: bool, s: u32, now: u64, last_user: u64, last_act: u64) -> bool { + if (god == false or open == false or s == A_NONE) { return false; } + return now >= last_user + GOD_WAIT and now >= last_act + GOD_WAIT; +} + +/// Cmd+J: stops GOD MODE first; with GOD MODE off it opens or closes the panel. Returns the panel +/// and GOD MODE after the key as 1 * open + 2 * god. +pub fn on_queen_key(open: bool, god: bool) -> u32 { + if (god) { return 1; } + if (open) { return 0; } + return 1; +} + +/// The sessions after the Queen runs `s` in `space`. +pub fn act_sessions(ss: u64, s: u32, space: u32) -> u64 { + if (s == A_OPEN) { return add_session(ss, NEW_CHAT_DEFAULT, space); } + return ss; +} + +/// The selected chat after the Queen runs `s`: the chat she opened, or the first of the space. +pub fn act_cur(ss: u64, s: u32, space: u32, cur: u32) -> u32 { + if (s == A_OPEN) { + if (session_count(add_session(ss, NEW_CHAT_DEFAULT, space)) > session_count(ss)) { return session_count(ss); } + return cur; + } + if (s == A_SELECT and sessions_in(ss, space) > 0) { return session_at_row(ss, space, 0); } + return cur; +} + +// ---- panel geometry ---- + +pub fn qp_left(sw: f64) -> f64 { + return sw - QP_W; +} + +fn in_rect(x: f64, y: f64, l: f64, t: f64, w: f64, h: f64) -> bool { + return x >= l and x < l + w and y >= t and y < t + h; +} + +/// True when (x, y) is on the panel; `top` is the top of the workspace. +pub fn on_panel(x: f64, y: f64, sw: f64, top: f64) -> bool { + return x >= qp_left(sw) and x <= sw and y >= top; +} + +pub fn on_panel_close(x: f64, y: f64, sw: f64, top: f64) -> bool { + return in_rect(x, y, sw - QP_PAD - QP_CLOSE, top + 12.0, QP_CLOSE, QP_CLOSE); +} + +pub fn on_god(x: f64, y: f64, sw: f64, top: f64) -> bool { + return in_rect(x, y, qp_left(sw) + QP_PAD, top + QP_GOD_Y, QP_W - 2.0 * QP_PAD, QP_GOD_H); +} + +pub fn on_confirm(x: f64, y: f64, sw: f64, top: f64) -> bool { + return in_rect(x, y, qp_left(sw) + QP_PAD, top + QP_BTN_Y, QP_BTN_W, QP_BTN_H); +} + +pub fn on_dismiss(x: f64, y: f64, sw: f64, top: f64) -> bool { + return in_rect(x, y, qp_left(sw) + QP_PAD + QP_BTN_W + QP_BTN_GAP, top + QP_BTN_Y, QP_BTN_W, QP_BTN_H); +} + +/// The RECENT lines that fit between QP_RECENT_Y and the bottom, at most RING. +pub fn recent_rows(sh: f64, top: f64) -> u32 { + const room : f64 = sh - top - QP_RECENT_Y - 24.0; + if (room <= 0.0) { return 0; } + const n : u32 = @as(u32, @intFromFloat(room / QP_LINE)); + if (n > RING) { return RING; } + return n; +} + +// ---- tests ---- + +test an_event_keeps_its_view_control_argument_and_time { + const e : u64 = ev_pack(3, C_NEW_CHAT, 7, 1792387306); + assert e != 0; + assert ev_view(e) == 3; + assert ev_control(e) == C_NEW_CHAT; + assert ev_arg(e) == 7; + assert ev_sec(e) == 1792387306; + // the largest of each field round-trips + const m : u64 = ev_pack(VIEW_HOME, C_COUNT - 1, 255, 4000000000); + assert ev_view(m) == VIEW_HOME; + assert ev_control(m) == C_COUNT - 1; + assert ev_arg(m) == 255; + assert ev_sec(m) == 4000000000; + // an empty slot reads as no control + assert ev_control(0) == C_NONE; + // every control fits the 6-bit field + assert C_COUNT <= 64; + assert view_of(false, 4) == VIEW_HOME; + assert view_of(true, 4) == 4; +} + +test the_ring_keeps_the_last_eight_newest_first { + assert ring_len(0) == 0; + assert ring_at(0, 0) == RING_NONE; + // three pushes: slots 0, 1, 2; the newest is slot 2 + assert ring_len(3) == 3; + assert ring_at(3, 0) == 2; + assert ring_at(3, 2) == 0; + assert ring_at(3, 3) == RING_NONE; + // eleven pushes wrap: the newest went to slot 2, the oldest kept is slot 3 + assert ring_next(10) == 2; + assert ring_len(11) == RING; + assert ring_at(11, 0) == 2; + assert ring_at(11, RING - 1) == 3; + assert ring_at(11, RING) == RING_NONE; +} + +test ages_read_as_words { + assert age_word(100, 100) == 0; + assert age_word(100, 97) == 0; + assert age_word(100, 50) == 1; + assert age_word(200, 100) == 2; + assert age_word(1000, 100) == 3; + assert age_word(10000, 100) == 4; + // a clock that moved back is "just now", not a huge age + assert age_word(100, 200) == 0; +} + +test the_queen_suggests_and_waits_for_a_human { + assert suggest(0, false) == A_OPEN; + assert suggest(2, false) == A_SELECT; + assert suggest(2, true) == A_NONE; + // dismissed stays hidden; another suggestion shows + assert shown_suggestion(A_OPEN, A_OPEN) == A_NONE; + assert shown_suggestion(A_SELECT, A_OPEN) == A_SELECT; + assert queen_control(A_OPEN) == C_QUEEN_OPENED; + assert by_queen(queen_control(A_SELECT)); + assert by_queen(C_NEW_CHAT) == false; + assert arg_kind(C_SPACE_ROW) == ARG_SPACE; + assert arg_kind(C_NEW_SPACE) == ARG_SPACE; + assert arg_kind(C_CLOSE_SPACE) == ARG_SPACE; + assert arg_kind(C_REPO_LINK) == ARG_NONE; + assert arg_kind(C_QUEEN_OPENED) == ARG_AGENT; + assert arg_kind(C_GOD_ON) == ARG_NONE; +} + +test god_mode_acts_only_when_on_open_and_still { + // off: never, however long the wait + assert god_due(false, true, A_OPEN, 1000, 0, 0) == false; + // the panel closed: never, the log would be off screen + assert god_due(true, false, A_OPEN, 1000, 0, 0) == false; + // nothing to do: never + assert god_due(true, true, A_NONE, 1000, 0, 0) == false; + // the user moved a second ago: wait + assert god_due(true, true, A_OPEN, 1000, 999, 0) == false; + // she acted a second ago: wait + assert god_due(true, true, A_OPEN, 1000, 0, 999) == false; + assert god_due(true, true, A_OPEN, 1000, 1000 - GOD_WAIT, 1000 - GOD_WAIT); + // Cmd+J stops GOD MODE and keeps the panel; otherwise it toggles the panel + assert on_queen_key(true, true) == 1; + assert on_queen_key(true, false) == 0; + assert on_queen_key(false, false) == 1; +} + +test the_panel_buttons_answer_only_on_their_boxes { + const sw : f64 = 1200.0; + const top : f64 = 35.0; + const l : f64 = qp_left(sw); + assert on_panel(l + 1.0, top + 1.0, sw, top); + assert on_panel(l - 1.0, top + 1.0, sw, top) == false; + assert on_panel(l + 1.0, top - 1.0, sw, top) == false; + assert on_god(l + 40.0, top + QP_GOD_Y + 10.0, sw, top); + assert on_confirm(l + QP_PAD + 1.0, top + QP_BTN_Y + 1.0, sw, top); + assert on_confirm(l + QP_PAD + QP_BTN_W + 4.0, top + QP_BTN_Y + 1.0, sw, top) == false; + assert on_dismiss(l + QP_PAD + QP_BTN_W + QP_BTN_GAP + 1.0, top + QP_BTN_Y + 1.0, sw, top); + assert on_panel_close(sw - QP_PAD - 8.0, top + 20.0, sw, top); + // the boxes do not overlap, top to bottom + assert QP_GOD_Y + QP_GOD_H < QP_CTX_Y; + assert QP_CTX_Y + 56.0 < QP_SUG_Y; + assert QP_BTN_Y + QP_BTN_H < QP_RECENT_Y; + assert 2.0 * QP_PAD + 2.0 * QP_BTN_W + QP_BTN_GAP < QP_W; + // RECENT fits what room there is + assert recent_rows(540.0, top) == RING; + assert recent_rows(300.0, top) == 0; +} + +test a_confirmed_suggestion_opens_or_selects_a_chat { + // an empty space: she opens Claude Code there and selects it + const s0 : u64 = 0; + assert suggest(sessions_in(s0, 1), false) == A_OPEN; + const s1 : u64 = act_sessions(s0, A_OPEN, 1); + const c1 : u32 = act_cur(s0, A_OPEN, 1, SESSION_NONE); + assert session_count(s1) == 1; + assert c1 == 0; + assert session_agent(s1, c1) == NEW_CHAT_DEFAULT; + // now there is a chat and it is selected: nothing more to do, so GOD MODE stops by itself + assert suggest(sessions_in(s1, 1), c1 != SESSION_NONE) == A_NONE; + // chats but none selected: she selects the first, the sessions unchanged + assert act_sessions(s1, A_SELECT, 1) == s1; + assert act_cur(s1, A_SELECT, 1, SESSION_NONE) == 0; + // nothing to do changes nothing + assert act_cur(s1, A_NONE, 1, SESSION_NONE) == SESSION_NONE; + assert NEW_CHAT_DEFAULT < AGENT_COUNT; +} + +test the_edge_glow_is_a_blue_gas_flame_and_only_in_god_mode { + // on exactly in GOD MODE, black edges in the human-confirm mode + assert glow_on(true); + assert glow_on(false) == false; + // a gas flame: blue leads at every stop; the cone is cyan (green over red), the base violet (red over green) + assert FLAME_CORE_B >= FLAME_CORE_G and FLAME_CORE_G > FLAME_CORE_R; + assert FLAME_MID_B > FLAME_MID_G and FLAME_MID_G > FLAME_MID_R; + assert FLAME_BASE_B > FLAME_BASE_R and FLAME_BASE_R > FLAME_BASE_G; + // the edge strip is the cone, the colour darkens inward, and stays blue all the way + assert flame_rgb(0, 0) == FLAME_CORE_R; + assert flame_rgb(0, 2) == FLAME_CORE_B; + assert flame_rgb(GLOW_BANDS - 1, 1) < flame_rgb(0, 1); + assert flame_rgb(GLOW_BANDS - 1, 2) > flame_rgb(GLOW_BANDS - 1, 0); + // the wave is smooth and bounded: 0 at the start of a period, 1 at its middle + assert flame_wave(0, BREATH_FRAMES) == 0.0; + assert flame_wave(BREATH_FRAMES / 2, BREATH_FRAMES) == 1.0; + assert flame_wave(BREATH_FRAMES, BREATH_FRAMES) == 0.0; + // breathing dims the flame by BREATH_AMP at most; it never goes out + assert flicker(0, 0) == 1.0; + assert flicker(BREATH_FRAMES / 2, 0) == 1.0 - BREATH_AMP; + assert flicker(7, GLOW_BANDS - 1) > 1.0 - BREATH_AMP - LICK_AMP; + // the tongues move: an outer strip is not the same on two nearby frames + assert flicker(0, GLOW_BANDS - 1) != flicker(5, GLOW_BANDS - 1); + assert core_alpha(1.0, 0) == GLOW_CORE_A; + assert core_alpha(0.0, 0) == 0.0; + // fades in and out, and stays inside 0..1 + assert glow_level(0.0, true) == GLOW_STEP; + assert glow_level(0.95, true) == 1.0; + assert glow_level(1.0, false) == 1.0 - GLOW_STEP; + assert glow_level(0.05, false) == 0.0; + assert glow_level(0.0, false) == 0.0; + // brightest at the edge, nothing at level 0 or past the last strip + assert glow_alpha(1.0, 0, 0) == GLOW_PEAK; + assert glow_alpha(1.0, 1, 0) < glow_alpha(1.0, 0, 0); + assert glow_alpha(0.0, 0, 0) == 0.0; + assert glow_alpha(1.0, GLOW_BANDS, 0) == 0.0; +} diff --git a/specs/t27os/session_store.t27 b/specs/t27os/session_store.t27 new file mode 100644 index 0000000000..0b968a1a3c --- /dev/null +++ b/specs/t27os/session_store.t27 @@ -0,0 +1,760 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/session_store.t27 -- the on-disk session manifest of the T27 OS PTY server and what a restart +; does with it. +; Owner, 2026-10-19: agents run as real terminals owned by T27 OS and every session comes back after a +; crash or a reboot (herdr model, see agents.t27). +; One file per pane under ~/.t27os/sessions, named pane-.rec, holding one line: +; v2 +; (a v1 line, the same without , is still read: its pane has no saved model) +; space, agent, slot and state are decimal numbers (agents.t27 cells and S_* states, windows.t27 spaces); +; slot is the pane's place in the layout; sid is the agent's own session id, "-" when not known yet; model is +; the model the pane was started with or switched to (claude --model ...), "-" when none; cwd is +; the rest of the line and may hold spaces. The server writes the file to pane-.tmp and renames it over +; pane-.rec on every change, so a crash leaves the old record or the new one, never half of one. +; On start the server reads every .rec file and, per record, restore_plan() decides: resume with the CLI's +; own flag and the saved sid, relaunch fresh in the cwd, or skip. The decisions are here; the host loop only +; reads and writes bytes. +; Learning the sid: Claude Code's SessionStart hook payload is JSON with "session_id" (json_field reads it); +; the fallback is the transcript file ~/.claude/projects//.jsonl +; (project_dir_name, uuid_from_jsonl). A sid reaches a command line only after sid_ok(). + +module T27osSessionStore; + +use t27os::agents::AGENT_COUNT; +use t27os::agents::AGENT_BINS; +use t27os::agents::RESUME_KIND; +use t27os::agents::RESUME_BY_ID; +use t27os::agents::RESUME_HISTORY; +use t27os::agents::RESUME_UNKNOWN; +use t27os::agents::S_DONE; +use t27os::agents::R_SKIP; +use t27os::agents::R_RESUME; +use t27os::agents::R_RELAUNCH; + +/// The resume flag (or subcommand) of each cell, in cell order; empty when none is established. +pub const RESUME_FLAGS : str = "--resume,resume,--resume,--session,,,--resume,--restore-chat-history,--resume"; +/// A pane that runs a plain shell instead of an agent CLI takes the cell number after the last agent. +pub const SHELL_CELL : u32 = 9; +pub const SHELL_BIN : str = "/bin/bash"; +pub const SID_NONE : u8 = 45; +pub const SID_MAX : usize = 64; +/// Record fields in the array parse_record fills. +pub const F_SPACE : usize = 0; +pub const F_AGENT : usize = 1; +pub const F_SLOT : usize = 2; +pub const F_STATE : usize = 3; +pub const F_SID_AT : usize = 4; +pub const F_SID_LEN : usize = 5; +pub const F_CWD_AT : usize = 6; +pub const F_CWD_LEN : usize = 7; +pub const F_MODEL_AT : usize = 8; +pub const F_MODEL_LEN : usize = 9; +pub const F_COUNT : usize = 10; +/// The model flag of each cell, in cell order; empty when the CLI takes none we have established. +pub const MODEL_FLAGS : str = "--model,--model,--model,,,,,--model,"; +pub const MODEL_MAX : usize = 60; + +/// Append the decimal digits of v at `at`; returns the position after them. +pub fn put_dec(out: []u8, at: usize, v: u64) -> usize { + var digits : u32 = 1; + var t : u64 = v; + while (t >= 10) { + t = t / 10; + digits = digits + 1; + } + var x : u64 = v; + var i : u32 = digits; + while (i > 0) { + out[at + i - 1] = @as(u8, @intCast(48 + x % 10)); + x = x / 10; + i = i - 1; + } + return at + digits; +} + +/// Append the bytes of s at `at`; returns the position after them. +pub fn put_bytes(out: []u8, at: usize, s: []const u8) -> usize { + var i : usize = 0; + while (i < s.len) { + out[at + i] = s[i]; + i = i + 1; + } + return at + s.len; +} + +/// Write the record line of one pane, newline included; returns its length. +pub fn put_record(out: []u8, space: u32, agent: u32, slot: u32, state: u32, sid: []const u8, model: []const u8, cwd: []const u8) -> usize { + var n : usize = put_bytes(out, 0, "v2 "); + n = put_dec(out, n, space); + out[n] = 32; + n = put_dec(out, n + 1, agent); + out[n] = 32; + n = put_dec(out, n + 1, slot); + out[n] = 32; + n = put_dec(out, n + 1, state); + out[n] = 32; + if (sid.len == 0) { + out[n + 1] = SID_NONE; + n = n + 2; + } else { + n = put_bytes(out, n + 1, sid); + } + out[n] = 32; + if (model_ok(model)) { + n = put_bytes(out, n + 1, model); + } else { + out[n + 1] = SID_NONE; + n = n + 2; + } + out[n] = 32; + n = put_bytes(out, n + 1, cwd); + out[n] = 10; + return n + 1; +} + +/// Read one decimal number at `at`; its value goes to f[slot] and the position after it comes back, +/// at when there is no digit (so the caller can tell). +fn read_dec(line: []const u8, at: usize, f: []u64, slot: usize) -> usize { + var i : usize = at; + var v : u64 = 0; + while (i < line.len and line[i] >= 48 and line[i] <= 57 and i - at < 10) { + v = v * 10 + @as(u64, line[i] - 48); + i = i + 1; + } + f[slot] = v; + return i; +} + +/// Parse a record line into f (F_* fields); false when the line is not a v1 or v2 record. A v1 line and a +/// v2 line with model "-" both leave F_MODEL_LEN at 0. +pub fn parse_record(line: []const u8, f: []u64) -> bool { + if (line.len < 12) { return false; } + if (line[0] != 118 or line[2] != 32) { return false; } + if (line[1] != 49 and line[1] != 50) { return false; } + var at : usize = 3; + var k : usize = 0; + while (k < 4) { + const e : usize = read_dec(line, at, f, k); + if (e == at or e >= line.len or line[e] != 32) { return false; } + at = e + 1; + k = k + 1; + } + var e2 : usize = at; + while (e2 < line.len and line[e2] != 32) { e2 = e2 + 1; } + if (e2 == at or e2 >= line.len) { return false; } + f[F_SID_AT] = at; + f[F_SID_LEN] = e2 - at; + var cat : usize = e2 + 1; + f[F_MODEL_AT] = cat; + f[F_MODEL_LEN] = 0; + if (line[1] == 50) { + var e3 : usize = cat; + while (e3 < line.len and line[e3] != 32) { e3 = e3 + 1; } + if (e3 == cat or e3 >= line.len) { return false; } + if (model_ok(line[cat .. e3])) { f[F_MODEL_LEN] = e3 - cat; } + cat = e3 + 1; + } + var end : usize = line.len; + if (line[end - 1] == 10) { end = end - 1; } + if (end <= cat) { return false; } + f[F_CWD_AT] = cat; + f[F_CWD_LEN] = end - cat; + if (f[F_AGENT] > AGENT_COUNT) { return false; } + return true; +} + +/// A session id is 1..SID_MAX characters of letters, digits and "-": nothing a shell or an argv splitter +/// would read as more than one word. "-" alone means "not known yet" and is not a sid. +pub fn sid_ok(s: []const u8) -> bool { + if (s.len == 0 or s.len > SID_MAX) { return false; } + if (s.len == 1 and s[0] == SID_NONE) { return false; } + var i : usize = 0; + while (i < s.len) { + const c : u8 = s[i]; + const alnum : bool = (c >= 48 and c <= 57) or (c >= 65 and c <= 90) or (c >= 97 and c <= 122); + if (!alnum and c != 45) { return false; } + i = i + 1; + } + return true; +} + +/// A model name is 1..MODEL_MAX characters of letters, digits and "-", ".", "_", "[", "]" (claude-opus-5-5, +/// opus[1m], gpt-5.1). build_command quotes it, so "[" never reaches the shell as a glob. "-" alone means none. +pub fn model_ok(s: []const u8) -> bool { + if (s.len == 0 or s.len > MODEL_MAX) { return false; } + if (s.len == 1 and s[0] == SID_NONE) { return false; } + var i : usize = 0; + while (i < s.len) { + const c : u8 = s[i]; + const alnum : bool = (c >= 48 and c <= 57) or (c >= 65 and c <= 90) or (c >= 97 and c <= 122); + const mark : bool = c == 45 or c == 46 or c == 95 or c == 91 or c == 93; + if (!alnum and !mark) { return false; } + i = i + 1; + } + return true; +} + +/// " ''" after a command line of n bytes, when the cell has a model flag and the model is +/// one model_ok() passes; n unchanged otherwise. +pub fn add_model(agent: u32, model: []const u8, out: []u8, n: usize) -> usize { + if (agent >= AGENT_COUNT or !model_ok(model)) { return n; } + var flag : [16]u8 = undefined; + const fl : usize = csv_field(MODEL_FLAGS, agent, flag[0..16]); + if (fl == 0) { return n; } + out[n] = 32; + var m : usize = put_bytes(out, n + 1, flag[0..fl]); + out[m] = 32; + out[m + 1] = 39; + m = put_bytes(out, m + 2, model); + out[m] = 39; + return m + 1; +} + +/// What restart does with a saved pane: skip a finished one, resume by the CLI's flag when the session is +/// known, relaunch fresh in the cwd otherwise. +pub fn restore_plan(agent: u32, state: u32, sid: []const u8) -> u32 { + if (state == S_DONE) { return R_SKIP; } + if (agent == SHELL_CELL) { return R_RELAUNCH; } + if (agent > SHELL_CELL) { return R_SKIP; } + const kind : u32 = RESUME_KIND[agent]; + if (kind == RESUME_UNKNOWN) { return R_RELAUNCH; } + if (kind == RESUME_HISTORY) { return R_RESUME; } + if (sid_ok(sid)) { return R_RESUME; } + return R_RELAUNCH; +} + +/// Copy the k-th comma-separated field of list into out; returns its length (0 when k is out of range). +pub fn csv_field(list: []const u8, k: u32, out: []u8) -> usize { + var idx : u32 = 0; + var i : usize = 0; + var n : usize = 0; + while (i < list.len) { + if (list[i] == 44) { + idx = idx + 1; + } else if (idx == k) { + out[n] = list[i]; + n = n + 1; + } + i = i + 1; + } + return n; +} + +/// The command line of a pane: the binary, then (on R_RESUME) the resume flag and the sid, then the model flag +/// and the quoted model when there is one (add_model), so a resumed or relaunched pane keeps its model. Words are +/// separated by one space; the server runs the line through /bin/sh -c (argv_text). Returns the length. +pub fn build_command(agent: u32, plan: u32, sid: []const u8, model: []const u8, out: []u8) -> usize { + if (agent == SHELL_CELL) { return put_bytes(out, 0, SHELL_BIN); } + var n : usize = csv_field(AGENT_BINS, agent, out); + if (plan != R_RESUME or agent >= AGENT_COUNT) { return add_model(agent, model, out, n); } + var flag : [32]u8 = undefined; + const fl : usize = csv_field(RESUME_FLAGS, agent, flag[0..32]); + if (fl == 0) { return add_model(agent, model, out, n); } + out[n] = 32; + n = n + 1; + var i : usize = 0; + while (i < fl) { + out[n + i] = flag[i]; + i = i + 1; + } + n = n + fl; + if (RESUME_KIND[agent] == RESUME_BY_ID) { + out[n] = 32; + n = put_bytes(out, n + 1, sid); + } + return add_model(agent, model, out, n); +} + +/// The index just past the first occurrence of needle in hay at or after `from`; hay.len + 1 when absent. +fn find_after(hay: []const u8, needle: []const u8, from: usize) -> usize { + if (needle.len == 0) { return hay.len + 1; } + var i : usize = from; + while (i + needle.len <= hay.len) { + var j : usize = 0; + while (j < needle.len and hay[i + j] == needle[j]) { j = j + 1; } + if (j == needle.len) { return i + needle.len; } + i = i + 1; + } + return hay.len + 1; +} + +/// The value of a string field in a flat JSON object, as (at, len) in f[0], f[1]: the hook payload +/// {"session_id":"..."}. Escapes are not interpreted; a value holding a backslash is refused. +pub fn json_field(src: []const u8, key: []const u8, f: []u64) -> bool { + var pat : [40]u8 = undefined; + if (key.len > 30) { return false; } + pat[0] = 34; + var n : usize = put_bytes(pat[0..40], 1, key); + pat[n] = 34; + pat[n + 1] = 58; + n = n + 2; + var at : usize = find_after(src, pat[0..n], 0); + if (at > src.len) { return false; } + while (at < src.len and src[at] == 32) { at = at + 1; } + if (at >= src.len or src[at] != 34) { return false; } + at = at + 1; + var e : usize = at; + while (e < src.len and src[e] != 34) { + if (src[e] == 92) { return false; } + e = e + 1; + } + if (e >= src.len) { return false; } + f[0] = at; + f[1] = e - at; + return true; +} + +/// Claude Code's project directory name for a cwd: "/" and "." become "-". +pub fn project_dir_name(cwd: []const u8, out: []u8) -> usize { + var i : usize = 0; + while (i < cwd.len) { + var c : u8 = cwd[i]; + if (c == 47 or c == 46) { c = 45; } + out[i] = c; + i = i + 1; + } + return cwd.len; +} + +/// The transcript of a Claude Code session: /.claude/projects//.jsonl, +/// NUL added for the open call. Returns the length without the NUL. +pub fn transcript_path(out: []u8, home: []const u8, cwd: []const u8, sid: []const u8) -> usize { + var n : usize = put_bytes(out, 0, home); + n = put_bytes(out, n, "/.claude/projects/"); + n = n + project_dir_name(cwd, out[n .. out.len]); + out[n] = 47; + n = put_bytes(out, n + 1, sid); + n = put_bytes(out, n, ".jsonl"); + out[n] = 0; + return n; +} + +/// The last model a transcript chunk names: every assistant line of a Claude Code transcript carries +/// "model":"", so the last one is the model the session ran on most recently, after any /model switch +/// (no hook reports a switch). As (at, len) in f[0], f[1]; values that fail model_ok() () are +/// passed over. false when the chunk names none. +pub fn last_model(buf: []const u8, f: []u64) -> bool { + const key : str = "\"model\":\""; + var found : bool = false; + var i : usize = 0; + while (i + key.len <= buf.len) { + var j : usize = 0; + while (j < key.len and buf[i + j] == key[j]) { j = j + 1; } + if (j == key.len) { + const at : usize = i + key.len; + var e : usize = at; + while (e < buf.len and buf[e] != 34 and e - at <= MODEL_MAX) { e = e + 1; } + if (e < buf.len and buf[e] == 34 and model_ok(buf[at .. e])) { + f[0] = at; + f[1] = e - at; + found = true; + } + i = at; + } else { + i = i + 1; + } + } + return found; +} + +fn is_hex(c: u8) -> bool { + return (c >= 48 and c <= 57) or (c >= 97 and c <= 102); +} + +/// True when name is ".jsonl": 36 characters with "-" at 8, 13, 18 and 23, then ".jsonl". +pub fn uuid_from_jsonl(name: []const u8) -> bool { + if (name.len != 42) { return false; } + var i : usize = 0; + while (i < 36) { + const dash : bool = i == 8 or i == 13 or i == 18 or i == 23; + if (dash and name[i] != 45) { return false; } + if (!dash and !is_hex(name[i])) { return false; } + i = i + 1; + } + return name[36] == 46 and name[37] == 106 and name[38] == 115 and name[39] == 111 and name[40] == 110 and name[41] == 108; +} + +/// The path of pane id's record: /.t27os/sessions/pane-.rec, or .tmp when tmp is true. +pub fn record_path(out: []u8, home: []const u8, id: u32, tmp: bool) -> usize { + var n : usize = put_bytes(out, 0, home); + n = put_bytes(out, n, "/.t27os/sessions/pane-"); + n = put_dec(out, n, id); + if (tmp) { return put_bytes(out, n, ".tmp"); } + return put_bytes(out, n, ".rec"); +} + +/// The pane id of a file name "pane-.rec"; 4294967295 when it is not one (a .tmp left by a crash). +pub fn pane_id_from_name(name: []const u8) -> u32 { + if (name.len < 10) { return 4294967295; } + if (name[0] != 112 or name[1] != 97 or name[2] != 110 or name[3] != 101 or name[4] != 45) { return 4294967295; } + const t : usize = name.len - 4; + if (name[t] != 46 or name[t + 1] != 114 or name[t + 2] != 101 or name[t + 3] != 99) { return 4294967295; } + var v : u64 = 0; + var i : usize = 5; + while (i < t) { + if (name[i] < 48 or name[i] > 57) { return 4294967295; } + v = v * 10 + @as(u64, name[i] - 48); + i = i + 1; + } + if (t == 5 or v > 9999) { return 4294967295; } + return @as(u32, @intCast(v)); +} + +/// The Claude Code hook events the server listens to, comma separated. +pub const HOOK_EVENTS : str = "SessionStart,UserPromptSubmit,Notification,Stop"; + +fn hex_digit(v: u32) -> u8 { + if (v < 10) { return @as(u8, @intCast(48 + v)); } + return @as(u8, @intCast(87 + v)); +} + +/// Format 16 random bytes as a version 4 UUID (36 characters, lowercase) in out; returns 36. +pub fn uuid_text(rnd: []const u8, out: []u8) -> usize { + var at : usize = 0; + var i : usize = 0; + while (i < 16) { + var b : u32 = @as(u32, rnd[i]); + if (i == 6) { b = (b & 15) + 64; } + if (i == 8) { b = (b & 63) + 128; } + if (i == 4 or i == 6 or i == 8 or i == 10) { + out[at] = 45; + at = at + 1; + } + out[at] = hex_digit(b / 16); + out[at + 1] = hex_digit(b % 16); + at = at + 2; + i = i + 1; + } + return at; +} + +/// The command line of a pane started fresh: Claude Code is given its session id up front +/// (claude --session-id ), so the manifest knows it without waiting for a hook; every other agent +/// starts bare and learns its id later, if ever. +pub fn fresh_command(agent: u32, sid: []const u8, model: []const u8, out: []u8) -> usize { + if (agent == 0 and sid_ok(sid)) { + var n : usize = put_bytes(out, 0, "claude --session-id "); + n = put_bytes(out, n, sid); + return add_model(agent, model, out, n); + } + return build_command(agent, R_RELAUNCH, sid, model, out); +} + +/// The Claude Code settings JSON that sends the four hook events to the server's own binary: each hook runs +/// `T27OS_HOOK=1 `, which reads the payload on stdin and forwards it to the socket. false when bin +/// holds a character that would end the JSON string. +pub fn hooks_json(out: []u8, bin: []const u8, f: []u64) -> bool { + var i : usize = 0; + while (i < bin.len) { + if (bin[i] == 34 or bin[i] == 92 or bin[i] < 32) { return false; } + i = i + 1; + } + var n : usize = put_bytes(out, 0, "{\"hooks\":{"); + var ev : [24]u8 = undefined; + var k : u32 = 0; + while (k < 4) { + const l : usize = csv_field(HOOK_EVENTS, k, ev[0..24]); + if (k > 0) { out[n] = 44; n = n + 1; } + out[n] = 34; + n = put_bytes(out, n + 1, ev[0..l]); + n = put_bytes(out, n, "\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"T27OS_HOOK=1 "); + n = put_bytes(out, n, bin); + n = put_bytes(out, n, "\"}]}]"); + k = k + 1; + } + n = put_bytes(out, n, "}}"); + f[0] = n; + return true; +} + +test a_record_round_trips { + var buf : [200]u8 = undefined; + var f : [10]u64 = undefined; + var agent : u32 = 0; + while (agent < AGENT_COUNT) { + const n : usize = put_record(buf[0..200], 2, agent, 7, 3, "5f1c-9a", "opus[1m]", "/Users/x/my repo"); + assert parse_record(buf[0..n], f[0..10]); + assert f[F_SPACE] == 2; + assert f[F_AGENT] == agent; + assert f[F_SLOT] == 7; + assert f[F_STATE] == 3; + assert f[F_SID_LEN] == 7; + assert f[F_CWD_LEN] == 16; + assert buf[f[F_CWD_AT] + 3] == 101; + assert f[F_MODEL_LEN] == 8; + assert buf[f[F_MODEL_AT]] == 111 and buf[f[F_MODEL_AT] + 7] == 93; + agent = agent + 1; + } +} + +test the_record_line_is_exact { + var buf : [100]u8 = undefined; + var sp : u32 = 12; + const n : usize = put_record(buf[0..100], sp, 0, 1, 3, "abc", "", "/t"); + assert n == 21; + assert buf[0] == 118 and buf[1] == 50 and buf[2] == 32; + assert buf[3] == 49 and buf[4] == 50 and buf[5] == 32; + assert buf[n - 1] == 10; + const m : usize = put_record(buf[0..100], 0, 1, 0, 0, "", "", "/t"); + assert buf[m - 5] == SID_NONE; + assert buf[m - 7] == SID_NONE; +} + +test a_damaged_record_is_refused { + var buf : [100]u8 = undefined; + var f : [10]u64 = undefined; + var n : usize = put_record(buf[0..100], 1, 0, 0, 1, "abc", "", "/t"); + assert parse_record(buf[0..n], f[0..10]); + buf[1] = 51; + assert parse_record(buf[0..n], f[0..10]) == false; + buf[1] = 50; + assert parse_record(buf[0..8], f[0..10]) == false; + n = put_record(buf[0..100], 1, 99, 0, 1, "abc", "", "/t"); + assert parse_record(buf[0..n], f[0..10]) == false; + n = put_record(buf[0..100], 1, 0, 0, 1, "abc", "", "/t"); + buf[3] = 120; + assert parse_record(buf[0..n], f[0..10]) == false; +} + +test a_v1_record_still_parses_without_a_model { + // the line a pre-v2 server wrote: no model field, the cwd still holds its space + const v1 : str = "v1 2 0 7 3 5f1c-9a /Users/x/my repo\n"; + var f : [10]u64 = undefined; + assert parse_record(v1, f[0..10]); + assert f[F_SPACE] == 2; + assert f[F_SLOT] == 7; + assert f[F_SID_LEN] == 7; + assert f[F_MODEL_LEN] == 0; + assert f[F_CWD_LEN] == 16; + assert f[F_CWD_AT] == 19; + // the same pane written back is v2, with model "-", and reads the same + var buf : [100]u8 = undefined; + const n : usize = put_record(buf[0..100], 2, 0, 7, 3, "5f1c-9a", "", "/Users/x/my repo"); + assert n == 38; + assert buf[1] == 50; + assert parse_record(buf[0..n], f[0..10]); + assert f[F_MODEL_LEN] == 0; + assert f[F_CWD_LEN] == 16; + // a v2 line whose model would not pass model_ok is read as having none + const bad : str = "v2 2 0 7 3 5f1c-9a a$b /t\n"; + assert parse_record(bad, f[0..10]); + assert f[F_MODEL_LEN] == 0; + // a v2 line cut before its cwd is refused + assert parse_record("v2 2 0 7 3 5f1c-9a opus\n", f[0..10]) == false; +} + +test only_plain_model_names_reach_a_command_line { + assert model_ok("claude-opus-5-5"); + assert model_ok("opus[1m]"); + assert model_ok("gpt-5.1_mini"); + assert model_ok("-") == false; + assert model_ok("") == false; + assert model_ok("a b") == false; + assert model_ok("a'b") == false; + assert model_ok("$(x)") == false; + assert model_ok("a;b") == false; +} + +test only_plain_ids_reach_a_command_line { + assert sid_ok("5f1c2c6e-9a41-4f0b-8c5d-0d3e5a7b9c11"); + assert sid_ok("-") == false; + assert sid_ok("") == false; + assert sid_ok("a b") == false; + assert sid_ok("a;b") == false; + assert sid_ok("$(x)") == false; + var long : [70]u8 = undefined; + var i : usize = 0; + while (i < 70) { long[i] = 97; i = i + 1; } + assert sid_ok(long[0..64]); + assert sid_ok(long[0..65]) == false; +} + +test restore_resumes_by_the_cli_flag_or_relaunches { + var a : u32 = 0; + // Claude Code with a known session id is resumed + assert restore_plan(a, 3, "5f1c-9a") == R_RESUME; + // the same pane without an id starts fresh in its cwd + assert restore_plan(a, 3, "-") == R_RELAUNCH; + assert restore_plan(a, 1, "") == R_RELAUNCH; + // a finished pane is skipped + assert restore_plan(a, S_DONE, "5f1c-9a") == R_SKIP; + // aider restores its chat history and needs no id + a = 7; + assert restore_plan(a, 1, "") == R_RESUME; + // crush has no established flag: relaunched + a = 5; + assert restore_plan(a, 1, "5f1c-9a") == R_RELAUNCH; + // a plain shell pane has no session to resume: it starts again in its cwd + a = SHELL_CELL; + assert restore_plan(a, 1, "-") == R_RELAUNCH; + assert restore_plan(a, S_DONE, "-") == R_SKIP; + a = SHELL_CELL + 1; + assert restore_plan(a, 1, "5f1c-9a") == R_SKIP; +} + +test the_command_line_carries_the_flag { + var out : [120]u8 = undefined; + var a : u32 = 0; + var n : usize = build_command(a, R_RESUME, "abc-1", "", out[0..120]); + // claude --resume abc-1 + assert n == 21; + assert out[0] == 99 and out[6] == 32 and out[7] == 45 and out[8] == 45; + assert out[n - 1] == 49; + n = build_command(a, R_RELAUNCH, "abc-1", "", out[0..120]); + assert n == 6; + a = 1; + n = build_command(a, R_RESUME, "abc-1", "", out[0..120]); + // codex resume abc-1 + assert n == 18; + assert out[6] == 114; + a = 7; + n = build_command(a, R_RESUME, "", "", out[0..120]); + // aider --restore-chat-history + assert n == 28; + a = 5; + n = build_command(a, R_RESUME, "abc-1", "", out[0..120]); + assert n == 5; +} + +test resume_and_relaunch_keep_the_model { + var out : [160]u8 = undefined; + var a : u32 = 0; + // claude --resume abc-1 --model 'opus' + var n : usize = build_command(a, R_RESUME, "abc-1", "opus", out[0..160]); + assert n == 36; + assert out[22] == 45 and out[24] == 109 and out[30] == 39 and out[n - 1] == 39; + // claude --model 'opus[1m]' + n = build_command(a, R_RELAUNCH, "abc-1", "opus[1m]", out[0..160]); + assert n == 25; + assert out[15] == 39 and out[20] == 91; + // a model that fails model_ok is left off + assert build_command(a, R_RESUME, "abc-1", "a b", out[0..160]) == 21; + assert build_command(a, R_RESUME, "abc-1", "-", out[0..160]) == 21; + // claude --session-id --model 'opus' + n = fresh_command(a, "5f1c2c6e-9a41-4f0b-8c5d-0d3e5a7b9c11", "opus", out[0..160]); + assert n == 20 + 36 + 15; + assert out[57] == 45 and out[58] == 45 and out[59] == 109; + // aider --restore-chat-history --model 'opus' + a = 7; + assert build_command(a, R_RESUME, "", "opus", out[0..160]) == 43; + // codex resume abc-1 --model 'opus' + a = 1; + assert build_command(a, R_RESUME, "abc-1", "opus", out[0..160]) == 33; + // crush has no established model flag; a shell pane has no model + a = 5; + assert build_command(a, R_RESUME, "abc-1", "opus", out[0..160]) == 5; + assert build_command(SHELL_CELL, R_RELAUNCH, "-", "opus", out[0..160]) == 9; +} + +test the_transcript_gives_the_last_model { + var f : [2]u64 = undefined; + const t : str = "{\"type\":\"assistant\",\"message\":{\"model\":\"claude-sonnet-5-5\"}}\n{\"type\":\"assistant\",\"message\":{\"model\":\"claude-opus-5-5\"}}\n{\"message\":{\"model\":\"\"}}\n"; + assert last_model(t, f[0..2]); + assert f[1] == 15; + assert t[@as(usize, @intCast(f[0])) + 7] == 111; + assert last_model("{\"type\":\"user\"}", f[0..2]) == false; + // a value cut at the end of the chunk is not taken + assert last_model("\"model\":\"claude-op", f[0..2]) == false; + var out : [120]u8 = undefined; + const n : usize = transcript_path(out[0..120], "/h", "/Users/x/my.repo", "abc-1"); + // /h/.claude/projects/-Users-x-my-repo/abc-1.jsonl + assert n == 2 + 18 + 16 + 1 + 5 + 6; + assert out[20] == 45 and out[n] == 0; +} + +test csv_fields_are_picked_by_position { + var out : [40]u8 = undefined; + var k : u32 = 0; + assert csv_field(RESUME_FLAGS, k, out[0..40]) == 8; + k = 4; + assert csv_field(RESUME_FLAGS, k, out[0..40]) == 0; + k = 7; + assert csv_field(RESUME_FLAGS, k, out[0..40]) == 22; + k = 20; + assert csv_field(RESUME_FLAGS, k, out[0..40]) == 0; +} + +test the_hook_payload_gives_the_session_id { + var f : [2]u64 = undefined; + var src : [100]u8 = undefined; + const n : usize = put_bytes(src[0..100], 0, "{\"hook_event_name\":\"SessionStart\",\"session_id\":\"5f1c-9a\",\"cwd\":\"/t\"}"); + assert json_field(src[0..n], "session_id", f[0..2]); + assert f[1] == 7; + assert src[f[0]] == 53; + assert json_field(src[0..n], "cwd", f[0..2]); + assert f[1] == 2; + assert json_field(src[0..n], "missing", f[0..2]) == false; + const m : usize = put_bytes(src[0..100], 0, "{\"session_id\":\"a\\\"b\"}"); + assert json_field(src[0..m], "session_id", f[0..2]) == false; +} + +test the_transcript_name_is_the_fallback { + var out : [60]u8 = undefined; + var p : [60]u8 = undefined; + const n : usize = project_dir_name("/Users/alice1/t27/.claude", out[0..60]); + assert n == 25; + assert out[0] == 45 and out[6] == 45 and out[13] == 45 and out[17] == 45 and out[18] == 45; + const m : usize = put_bytes(p[0..60], 0, "5f1c2c6e-9a41-4f0b-8c5d-0d3e5a7b9c11.jsonl"); + assert uuid_from_jsonl(p[0..m]); + p[8] = 120; + assert uuid_from_jsonl(p[0..m]) == false; + p[8] = 45; + p[0] = 71; + assert uuid_from_jsonl(p[0..m]) == false; + p[0] = 53; + assert uuid_from_jsonl(p[0..m - 1]) == false; +} + +test record_paths_and_names_agree { + var out : [100]u8 = undefined; + var id : u32 = 0; + while (id < 12) { + const n : usize = record_path(out[0..100], "/h", id, false); + var from : usize = 0; + while (from < n and !(out[from] == 112 and out[from + 1] == 97 and out[from + 2] == 110 and out[from + 3] == 101)) { from = from + 1; } + assert pane_id_from_name(out[from..n]) == id; + id = id + 1; + } + const t : usize = record_path(out[0..100], "/h", 7, true); + assert pane_id_from_name(out[t - 10..t]) == 4294967295; +} + +test a_fresh_claude_pane_carries_its_session_id { + var rnd : [16]u8 = undefined; + var i : usize = 0; + while (i < 16) { rnd[i] = @as(u8, @intCast(i * 17)); i = i + 1; } + var id : [40]u8 = undefined; + assert uuid_text(rnd[0..16], id[0..40]) == 36; + var name : [60]u8 = undefined; + const m : usize = put_bytes(name[0..60], 0, id[0..36]); + const m2 : usize = put_bytes(name[0..60], m, ".jsonl"); + assert uuid_from_jsonl(name[0..m2]); + // version 4 and the RFC 4122 variant + assert id[14] == 52; + assert id[19] == 56 or id[19] == 57 or id[19] == 97 or id[19] == 98; + assert sid_ok(id[0..36]); + var out : [120]u8 = undefined; + const n : usize = fresh_command(0, id[0..36], "", out[0..120]); + assert n == 20 + 36; + assert out[7] == 45 and out[8] == 45 and out[9] == 115; + // anything else starts bare + assert fresh_command(1, id[0..36], "", out[0..120]) == 5; + assert fresh_command(SHELL_CELL, "-", "", out[0..120]) == 9; + assert fresh_command(0, "bad id", "", out[0..120]) == 6; +} + +test the_hook_settings_are_json_that_names_the_binary { + var out : [600]u8 = undefined; + var f : [1]u64 = undefined; + assert hooks_json(out[0..600], "/Users/x/t27os-pty", f[0..1]); + const n : usize = f[0]; + assert out[0] == 123 and out[n - 1] == 125 and out[n - 2] == 125; + assert find_after(out[0..n], "\"SessionStart\":[", 0) <= n; + assert find_after(out[0..n], "\"UserPromptSubmit\":[", 0) <= n; + assert find_after(out[0..n], "\"Notification\":[", 0) <= n; + assert find_after(out[0..n], "\"Stop\":[", 0) <= n; + assert find_after(out[0..n], "T27OS_HOOK=1 /Users/x/t27os-pty\"", 0) <= n; + assert find_after(out[0..n], "\"Idle\":[", 0) > n; + assert hooks_json(out[0..600], "/a\"b", f[0..1]) == false; + assert hooks_json(out[0..600], "/a\\b", f[0..1]) == false; +} diff --git a/specs/t27os/term_pane.t27 b/specs/t27os/term_pane.t27 new file mode 100644 index 0000000000..20fed28a2b --- /dev/null +++ b/specs/t27os/term_pane.t27 @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/term_pane.t27 -- the window side of a terminal pane: where the cell grid sits, how big a cell +; is, which agent rows open one, and which bytes a key press sends to the PTY. +; Owner, 2026-10-19: Claude Code runs inside the T27 OS window. Clicking the Claude Code entry under AGENTS +; opens a terminal pane in the current space's directory; the pane draws a grid of codepoints, monospace, +; black and white, with a cursor, and every key typed in it goes to the agent as bytes. +; The grid itself is specs/t27os/vt.t27 (bytes in, cells out). The PTY lives in the T27 OS terminal server +; and is reached through specs/t27os/pty_client.t27 (connect, list, spawn, keys, read). When no server +; answers on ~/.t27os/pty.sock the pane is OFFLINE: it says so on its first lines and echoes what it would +; send, and the window tries to connect again every TP_RETRY frames. +; The window stores no pane ids. Its k-th chat of an agent is the server's k-th pane of that agent +; (tp_pick_pane over a LIST reply), so a window that quits and reopens finds the same panes; a chat with no +; pane yet spawns one. When the grid that fits changes, the window sends RESIZE; the server sets the pty +; size (__ioctl TIOCSWINSZ) and signals SIGWINCH, so the program redraws at the pane's full width. +; The window keeps one grid. The server keeps every pane's output, so selecting another chat re-initialises +; the grid and re-attaches; nothing here is the store of a session's history. + +module T27osTermPane; + +use t27os::pty_proto::HEADER; +use t27os::pty_proto::RE_OK; +use t27os::pty_proto::RE_DATA; +use t27os::pty_proto::RE_LISTING; +use t27os::windows::session_agent; + +/// The agent whose rows open a terminal pane: AGENT 0, Claude Code (agents.t27 AGENT_NAMES order). +pub const TERM_AGENT : u32 = 0; +/// The program a pane starts for TERM_AGENT, in the space's directory (agents.t27 AGENT_BINS). +pub const TERM_BIN : str = "claude"; + +/// Grid bounds. The host owns one [TP_CELLS]u32 buffer; a pane never asks for more. +pub const TP_COLS_MAX : u32 = 400; +pub const TP_ROWS_MAX : u32 = 120; +pub const TP_CELLS : u32 = 48000; +pub const TP_COLS_MIN : u32 = 20; +pub const TP_ROWS_MIN : u32 = 4; +/// One row of UTF-8 for drawing: four bytes a cell at most. +pub const TP_ROW_BYTES : u32 = 1600; + +/// Cell metrics, in points: Menlo 12 advances 7.22 pt; a line is 15 pt. The grid sits TP_PAD inside the +/// content area below the header. +pub const TP_FONT : f64 = 12.0; +pub const TP_CELL_W : f64 = 7.22; +pub const TP_LINE_H : f64 = 15.0; +pub const TP_PAD : f64 = 14.0; +/// The baseline sits this far below a row's top. +pub const TP_BASE : f64 = 11.5; +/// The cursor is a block one cell wide; it blinks with a period of TP_BLINK frames (30 fps), and stays +/// solid for TP_BLINK frames after any key so typing never hides it. +pub const TP_BLINK : u32 = 30; + +/// Pane link state. +pub const PANE_OFFLINE : u32 = 0; +pub const PANE_LIVE : u32 = 1; +pub const PANE_EXITED : u32 = 2; +/// No pane open. +pub const PANE_NONE : u32 = 999; +/// Frames between connect attempts while the pane is OFFLINE (30 fps: three seconds). +pub const TP_RETRY : u32 = 90; +/// READ round trips a frame makes at most. Each brings up to 4096 bytes, so a fresh grid replays the +/// server's whole 64 KiB ring in one frame and a busy pane never stalls the window for longer. +pub const TP_READS_MAX : u32 = 16; +/// Where the output bytes start in a RE_DATA reply: after the header and the 8-byte cursor. +pub const TP_DATA_AT : usize = 12; + +/// The lines an OFFLINE pane prints first; the host writes the directory after TP_LINE_CWD, and the echo +/// starts after TP_PROMPT. +pub const TP_LINE_TITLE : str = "T27 OS terminal -- Claude Code"; +pub const TP_LINE_CWD : str = "cwd "; +pub const TP_LINE_OFFLINE : str = "offline: no t27os pty server on ~/.t27os/pty.sock; keys echo here"; +pub const TP_PROMPT : str = "> "; +/// The directory buffer the host fills for TP_LINE_CWD. +pub const TP_PATH_MAX : u32 = 512; + +/// macOS virtual key codes the pane maps itself; every other key sends its characters. +pub const VK_RETURN : u32 = 36; +pub const VK_TAB : u32 = 48; +pub const VK_DELETE : u32 = 51; +pub const VK_ESCAPE : u32 = 53; +pub const VK_ENTER_PAD : u32 = 76; +pub const VK_HOME : u32 = 115; +pub const VK_PAGE_UP : u32 = 116; +pub const VK_FWD_DELETE : u32 = 117; +pub const VK_END : u32 = 119; +pub const VK_PAGE_DOWN : u32 = 121; +pub const VK_LEFT : u32 = 123; +pub const VK_RIGHT : u32 = 124; +pub const VK_DOWN : u32 = 125; +pub const VK_UP : u32 = 126; +/// The longest sequence one key sends. +pub const TP_KEY_MAX : u32 = 8; + +/// Does a selected chat of this agent show a terminal pane? +pub fn opens_pane(agent: u32) -> bool { + return agent == TERM_AGENT; +} + +/// The grid that fits w x h points of content area (header excluded), clamped to the buffer. +pub fn tp_cols(w: f64) -> u32 { + const avail : f64 = w - 2.0 * TP_PAD; + if (avail < TP_CELL_W * @as(f64, TP_COLS_MIN)) { return TP_COLS_MIN; } + const n : u32 = @as(u32, @intFromFloat(avail / TP_CELL_W)); + if (n > TP_COLS_MAX) { return TP_COLS_MAX; } + return n; +} + +pub fn tp_rows(h: f64) -> u32 { + const avail : f64 = h - 2.0 * TP_PAD; + if (avail < TP_LINE_H * @as(f64, TP_ROWS_MIN)) { return TP_ROWS_MIN; } + const n : u32 = @as(u32, @intFromFloat(avail / TP_LINE_H)); + if (n > TP_ROWS_MAX) { return TP_ROWS_MAX; } + return n; +} + +/// A resize is due when the grid that fits differs from the one the pane has. +pub fn tp_resize_due(cols: u32, rows: u32, w: f64, h: f64) -> bool { + return cols != tp_cols(w) or rows != tp_rows(h); +} + +/// Frames the window's size must hold before the pane takes it (audit 2026-10-19: a drag re-ran the grid +/// and the ring replay on every frame). About 0.2 s at 30 fps; hot_reload.t27 waits the same way. +pub const TP_SETTLE : u32 = 6; + +/// Frames the wanted grid has held, after this frame: a different grid starts the count over. +pub fn tp_settle(stable: u32, same: bool) -> u32 { + if (same == false) { return 0; } + if (stable >= TP_SETTLE) { return TP_SETTLE; } + return stable + 1; +} + +/// The pane takes a new size once the grid that fits differs from its own and has held TP_SETTLE frames. +pub fn tp_size_due(cols: u32, rows: u32, w: f64, h: f64, stable: u32) -> bool { + return stable >= TP_SETTLE and tp_resize_due(cols, rows, w, h); +} + +/// Where cell (x, y) starts, from the content area's top-left (ox, oy). +pub fn tp_cell_x(ox: f64, x: u32) -> f64 { return ox + TP_PAD + @as(f64, x) * TP_CELL_W; } +pub fn tp_cell_y(oy: f64, y: u32) -> f64 { return oy + TP_PAD + @as(f64, y) * TP_LINE_H; } + +/// The cursor column shown: a cursor parked past the last column (deferred wrap) draws on the last one. +pub fn tp_cursor_col(x: u32, cols: u32) -> u32 { + if (cols == 0) { return 0; } + if (x >= cols) { return cols - 1; } + return x; +} + +/// Is the cursor drawn this frame? `since` is frames since the last key. +pub fn tp_cursor_on(frame: u32, since: u32) -> bool { + if (since < TP_BLINK) { return true; } + return frame / TP_BLINK % 2 == 0; +} + +/// Opening a pane for chat `cur` resets the grid unless that chat's pane is already the one shown. +pub fn tp_reset_due(open_si: u32, cur: u32) -> bool { + return open_si != cur; +} + +fn tp_put(out: []u8, at: u32, b: u8) -> u32 { + if (at < TP_KEY_MAX) { out[at] = b; } + return at + 1; +} + +fn tp_csi(out: []u8, fin: u8) -> u32 { + var n : u32 = tp_put(out, 0, 27); + n = tp_put(out, n, 91); + return tp_put(out, n, fin); +} + +fn tp_csi_tilde(out: []u8, d: u8) -> u32 { + var n : u32 = tp_put(out, 0, 27); + n = tp_put(out, n, 91); + n = tp_put(out, n, d); + return tp_put(out, n, 126); +} + +/// UTF-8 of one codepoint into out at `at`; returns the new end. A surrogate or out-of-range codepoint +/// writes U+FFFD. +pub fn tp_utf8(out: []u8, at: u32, cp0: u32) -> u32 { + var cp : u32 = cp0; + if (cp > 1114111 or (cp >= 55296 and cp <= 57343)) { cp = 65533; } + if (cp < 128) { + out[at] = @as(u8, @intCast(cp)); + return at + 1; + } + if (cp < 2048) { + out[at] = @as(u8, @intCast(192 + cp / 64)); + out[at + 1] = @as(u8, @intCast(128 + cp % 64)); + return at + 2; + } + if (cp < 65536) { + out[at] = @as(u8, @intCast(224 + cp / 4096)); + out[at + 1] = @as(u8, @intCast(128 + cp / 64 % 64)); + out[at + 2] = @as(u8, @intCast(128 + cp % 64)); + return at + 3; + } + out[at] = @as(u8, @intCast(240 + cp / 262144)); + out[at + 1] = @as(u8, @intCast(128 + cp / 4096 % 64)); + out[at + 2] = @as(u8, @intCast(128 + cp / 64 % 64)); + out[at + 3] = @as(u8, @intCast(128 + cp % 64)); + return at + 4; +} + +/// The bytes one key press sends to the PTY: `key` is the virtual key code, `cp` the first character the +/// key types without modifiers (0 when it types none), `ctrl` whether Control is held. Command is the +/// window's (the host never forwards a Command key). Returns how many bytes went into out (TP_KEY_MAX long). +pub fn tp_key_bytes(key: u32, cp: u32, ctrl: bool, out: []u8) -> u32 { + if (key == VK_RETURN or key == VK_ENTER_PAD) { return tp_put(out, 0, 13); } + if (key == VK_TAB) { return tp_put(out, 0, 9); } + if (key == VK_DELETE) { return tp_put(out, 0, 127); } + if (key == VK_ESCAPE) { return tp_put(out, 0, 27); } + if (key == VK_UP) { return tp_csi(out, 65); } + if (key == VK_DOWN) { return tp_csi(out, 66); } + if (key == VK_RIGHT) { return tp_csi(out, 67); } + if (key == VK_LEFT) { return tp_csi(out, 68); } + if (key == VK_HOME) { return tp_csi(out, 72); } + if (key == VK_END) { return tp_csi(out, 70); } + if (key == VK_FWD_DELETE) { return tp_csi_tilde(out, 51); } + if (key == VK_PAGE_UP) { return tp_csi_tilde(out, 53); } + if (key == VK_PAGE_DOWN) { return tp_csi_tilde(out, 54); } + if (cp == 0) { return 0; } + if (ctrl) { + // Control folds a letter or one of @[\]^_ to its C0 code; Control+space is NUL + if (cp >= 97 and cp <= 122) { return tp_put(out, 0, @as(u8, @intCast(cp - 96))); } + if (cp >= 64 and cp <= 95) { return tp_put(out, 0, @as(u8, @intCast(cp - 64))); } + if (cp == 32) { return tp_put(out, 0, 0); } + } + return tp_utf8(out, 0, cp); +} + +/// OFFLINE echo: what the grid is fed for the bytes a key would send, so the echo reads like a shell line. +/// Return echoes CR LF, Delete echoes BS space BS; everything else echoes as sent. Returns the length. +pub fn tp_echo(sent: []const u8, n: u32, out: []u8) -> u32 { + if (n == 1 and sent[0] == 13) { + out[0] = 13; + out[1] = 10; + return 2; + } + if (n == 1 and sent[0] == 127) { + out[0] = 8; + out[1] = 32; + out[2] = 8; + return 3; + } + var i : u32 = 0; + while (i < n) { + out[i] = sent[i]; + i = i + 1; + } + return n; +} + +/// The k-th pane running `agent` in a LIST reply (`len` bytes of frame: the header, then pane, agent, +/// state and flags per entry); PANE_NONE when the server has fewer, or the frame is not a listing. +pub fn tp_pick_pane(frame: []const u8, len: usize, agent: u32, k: u32) -> u32 { + if (len < HEADER or frame[0] != RE_LISTING) { return PANE_NONE; } + var seen : u32 = 0; + var found : u32 = PANE_NONE; + var at : usize = HEADER; + while (at + 4 <= len and found == PANE_NONE) { + if (@as(u32, frame[at + 1]) == agent) { + if (seen == k) { found = @as(u32, frame[at]); } + seen = seen + 1; + } + at = at + 4; + } + return found; +} + +/// Which of the window's chats of its own agent chat `cur` is: 0 for the first, 1 for the second, ... +pub fn tp_ordinal(ss: u64, cur: u32) -> u32 { + const a : u32 = session_agent(ss, cur); + var k : u32 = 0; + var i : u32 = 0; + while (i < cur) { + if (session_agent(ss, i) == a) { k = k + 1; } + i = i + 1; + } + return k; +} + +/// The pane a SPAWN reply names, PANE_NONE when the server refused. +pub fn tp_spawned(frame: []const u8, len: usize) -> u32 { + if (len < HEADER or frame[0] != RE_OK) { return PANE_NONE; } + return @as(u32, frame[1]); +} + +/// How many output bytes a READ reply carries (from TP_DATA_AT); 0 for anything but RE_DATA. +pub fn tp_data_len(frame: []const u8, len: usize) -> usize { + if (len < TP_DATA_AT or frame[0] != RE_DATA) { return 0; } + return len - TP_DATA_AT; +} + +/// Another READ this frame? Only while the last one came back full and the budget lasts. +pub fn tp_read_more(got: usize, reads: u32) -> bool { + return got == 4096 and reads < TP_READS_MAX; +} + +/// Try to connect this frame? At once on a fresh pane, then every TP_RETRY frames while offline. +pub fn tp_retry_due(since_try: u32) -> bool { + return since_try >= TP_RETRY; +} + +test claude_code_rows_open_a_pane { + assert opens_pane(0); + assert opens_pane(1) == false; + assert opens_pane(4) == false; +} + +test the_grid_fits_the_content_area { + // 760 x 452 pt of content: (760 - 28) / 7.22 = 101 columns, (452 - 28) / 15 = 28 rows + assert tp_cols(760.0) == 101; + assert tp_rows(452.0) == 28; + assert tp_cols(10.0) == TP_COLS_MIN; + assert tp_rows(10.0) == TP_ROWS_MIN; + assert tp_cols(5000.0) == TP_COLS_MAX; + assert tp_rows(5000.0) == TP_ROWS_MAX; + assert TP_COLS_MAX * TP_ROWS_MAX <= TP_CELLS; + assert TP_COLS_MAX * 4 <= TP_ROW_BYTES; + assert tp_resize_due(101, 28, 760.0, 452.0) == false; + assert tp_resize_due(80, 24, 760.0, 452.0); +} + +test cells_and_cursor_sit_on_the_grid { + assert tp_cell_x(200.0, 0) == 214.0; + assert tp_cell_y(44.0, 2) == 88.0; + assert tp_cursor_col(5, 80) == 5; + assert tp_cursor_col(80, 80) == 79; + assert tp_cursor_on(45, 0); + assert tp_cursor_on(0, 99); + assert tp_cursor_on(45, 99) == false; + assert tp_reset_due(PANE_NONE, 0); + assert tp_reset_due(2, 2) == false; +} + +test keys_become_terminal_bytes { + var o : [8]u8 = undefined; + assert tp_key_bytes(VK_RETURN, 13, false, o[0..8]) == 1 and o[0] == 13; + assert tp_key_bytes(VK_DELETE, 127, false, o[0..8]) == 1 and o[0] == 127; + assert tp_key_bytes(VK_TAB, 9, false, o[0..8]) == 1 and o[0] == 9; + assert tp_key_bytes(VK_ESCAPE, 27, false, o[0..8]) == 1 and o[0] == 27; + assert tp_key_bytes(VK_UP, 0, false, o[0..8]) == 3 and o[0] == 27 and o[1] == 91 and o[2] == 65; + assert tp_key_bytes(VK_LEFT, 0, false, o[0..8]) == 3 and o[2] == 68; + assert tp_key_bytes(VK_FWD_DELETE, 0, false, o[0..8]) == 4 and o[2] == 51 and o[3] == 126; + // a letter sends itself; Control+c sends ETX; Control+[ sends ESC + assert tp_key_bytes(0, 97, false, o[0..8]) == 1 and o[0] == 97; + assert tp_key_bytes(8, 99, true, o[0..8]) == 1 and o[0] == 3; + assert tp_key_bytes(33, 91, true, o[0..8]) == 1 and o[0] == 27; + assert tp_key_bytes(49, 32, true, o[0..8]) == 1 and o[0] == 0; + // a modifier alone types nothing and sends nothing + assert tp_key_bytes(56, 0, false, o[0..8]) == 0; +} + +test codepoints_encode_as_utf8 { + var o : [8]u8 = undefined; + assert tp_utf8(o[0..8], 0, 65) == 1 and o[0] == 65; + // U+00E9 is C3 A9 + assert tp_utf8(o[0..8], 0, 233) == 2 and o[0] == 195 and o[1] == 169; + // U+2500 (box drawing) is E2 94 80 + assert tp_utf8(o[0..8], 0, 9472) == 3 and o[0] == 226 and o[1] == 148 and o[2] == 128; + // U+1F600 is F0 9F 98 80 + assert tp_utf8(o[0..8], 0, 128512) == 4 and o[0] == 240 and o[1] == 159 and o[2] == 152 and o[3] == 128; + // a lone surrogate becomes U+FFFD, EF BF BD + assert tp_utf8(o[0..8], 0, 55296) == 3 and o[0] == 239 and o[1] == 191 and o[2] == 189; +} + +test offline_echo_reads_like_a_line { + var s : [8]u8 = undefined; + var o : [8]u8 = undefined; + s[0] = 13; + assert tp_echo(s[0..8], 1, o[0..8]) == 2 and o[0] == 13 and o[1] == 10; + s[0] = 127; + assert tp_echo(s[0..8], 1, o[0..8]) == 3 and o[0] == 8 and o[1] == 32 and o[2] == 8; + s[0] = 104; + assert tp_echo(s[0..8], 1, o[0..8]) == 1 and o[0] == 104; +} + +test a_reopened_window_finds_its_panes { + // LIST reply: header, then panes 3 (claude), 5 (codex), 7 (claude) + var f : [16]u8 = undefined; + f[0] = RE_LISTING; f[1] = 0; f[2] = 12; f[3] = 0; + f[4] = 3; f[5] = 0; f[6] = 1; f[7] = 1; + f[8] = 5; f[9] = 1; f[10] = 1; f[11] = 1; + f[12] = 7; f[13] = 0; f[14] = 1; f[15] = 1; + assert tp_pick_pane(f[0..16], 16, 0, 0) == 3; + assert tp_pick_pane(f[0..16], 16, 0, 1) == 7; + assert tp_pick_pane(f[0..16], 16, 0, 2) == PANE_NONE; + assert tp_pick_pane(f[0..16], 16, 1, 0) == 5; + assert tp_pick_pane(f[0..16], 4, 0, 0) == PANE_NONE; + f[0] = RE_OK; + assert tp_pick_pane(f[0..16], 16, 0, 0) == PANE_NONE; + // chats: claude in space 0, codex in space 0, claude in space 1 (slot byte = agent * 8 + space + 1) + const ss : u64 = 1 + 9 * 256 + 2 * 65536; + assert tp_ordinal(ss, 0) == 0; + assert tp_ordinal(ss, 1) == 0; + assert tp_ordinal(ss, 2) == 1; +} + +test replies_read_as_pane_and_bytes { + var f : [20]u8 = undefined; + f[0] = RE_OK; f[1] = 6; f[2] = 0; f[3] = 0; + assert tp_spawned(f[0..20], 4) == 6; + assert tp_spawned(f[0..20], 3) == PANE_NONE; + f[0] = RE_DATA; + assert tp_spawned(f[0..20], 4) == PANE_NONE; + assert TP_DATA_AT == HEADER + 8; + assert tp_data_len(f[0..20], 20) == 8; + assert tp_data_len(f[0..20], 12) == 0; + assert tp_data_len(f[0..20], 11) == 0; + f[0] = RE_OK; + assert tp_data_len(f[0..20], 20) == 0; + assert tp_read_more(4096, 1); + assert tp_read_more(4096, TP_READS_MAX) == false; + assert tp_read_more(100, 1) == false; + assert tp_retry_due(0) == false; + assert tp_retry_due(TP_RETRY); + // the whole ring replays in one frame's budget + assert @as(u64, TP_READS_MAX) * 4096 >= 65536; +} + +invariant grid_buffer_holds_the_largest_pane { + assert TP_COLS_MAX * TP_ROWS_MAX <= TP_CELLS; +} + +test a_resize_waits_until_the_size_holds { + assert tp_settle(0, false) == 0; + assert tp_settle(3, false) == 0; + assert tp_settle(3, true) == 4; + assert tp_settle(TP_SETTLE, true) == TP_SETTLE; + assert tp_size_due(80, 24, 760.0, 452.0, TP_SETTLE - 1) == false; + assert tp_size_due(80, 24, 760.0, 452.0, TP_SETTLE); + assert tp_size_due(101, 28, 760.0, 452.0, TP_SETTLE) == false; +} diff --git a/specs/t27os/text_cache.t27 b/specs/t27os/text_cache.t27 new file mode 100644 index 0000000000..d7a02ca521 --- /dev/null +++ b/specs/t27os/text_cache.t27 @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/text_cache.t27 -- which text line the T27 OS window may reuse, and how wide a sidebar label may run. +; Audit, 2026-10-19 (epic #10782, M11): every label was laid out from scratch on every drawn frame -- a font +; lookup, an attribute dictionary, an attributed string and a CoreText line, then thrown away. The window now +; keeps each laid-out line in a table keyed by its style and its text (the text compared by content, so a +; renamed space never shows a stale line) and lays it out again only on a miss. The table is emptied once it +; holds TC_CAP lines, which bounds it without a per-entry age. A label that would run past the edge of its +; column is cut by CoreText with an ellipsis to the width left; that width is part of the style, so the +; same text at two widths is two entries. + +module T27osTextCache; + +/// Lines the table may hold before it is emptied. +pub const TC_CAP : u64 = 512; +/// The narrowest room worth drawing a cut label into, in points; below it the label is left out. +pub const TC_MIN_ROOM : f64 = 18.0; +/// No cut: the style of a label that may run as wide as it likes. +pub const TC_FREE : f64 = 0.0; + +/// A point value as quarter points, clamped to 16 bits. +pub fn tc_q(p: f64) -> u64 { + if (p <= 0.0) { return 0; } + if (p >= 16383.0) { return 65535; } + return @as(u64, @intFromFloat(p * 4.0)); +} + +/// The style part of a line's key: size, weight, mono, and the cut width (TC_FREE for none). +/// Weight is an NSFontWeight in [-1, 1], shifted to [0, 2] first. +pub fn tc_style(size: f64, weight: f64, mono: bool, cut: f64) -> u64 { + var m : u64 = 0; + if (mono) { m = 1; } + return (tc_q(size) << 33) | (tc_q(weight + 1.0) << 17) | (m << 16) | tc_q(cut); +} + +/// Empty the table before adding to it? +pub fn tc_flush(count: u64) -> bool { + return count >= TC_CAP; +} + +/// The room a label starting at x has before `right`, or 0 when it is under TC_MIN_ROOM. +pub fn tc_room(x: f64, right: f64) -> f64 { + const r : f64 = right - x; + if (r < TC_MIN_ROOM) { return 0.0; } + return r; +} + +/// Cut a line `w` wide to `room`? Only when a cut width is set and the line overflows it. +pub fn tc_cut(w: f64, room: f64) -> bool { + return room > 0.0 and w > room; +} + +test styles_differ_in_every_part { + const a : u64 = tc_style(11.5, 0.23, false, TC_FREE); + assert a != tc_style(12.0, 0.23, false, TC_FREE); + assert a != tc_style(11.5, 0.3, false, TC_FREE); + assert a != tc_style(11.5, 0.23, true, TC_FREE); + assert a != tc_style(11.5, 0.23, false, 200.0); + assert a == tc_style(11.5, 0.23, false, TC_FREE); + // a light weight (negative) and a heavy one stay apart + assert tc_style(10.0, -0.4, false, TC_FREE) != tc_style(10.0, 0.4, false, TC_FREE); +} + +test quarter_points_clamp { + assert tc_q(-2.0) == 0; + assert tc_q(1.0) == 4; + assert tc_q(11.5) == 46; + assert tc_q(99999.0) == 65535; +} + +test the_table_empties_at_the_cap { + assert tc_flush(0) == false; + assert tc_flush(TC_CAP - 1) == false; + assert tc_flush(TC_CAP); +} + +test a_label_is_cut_only_when_it_overflows { + assert tc_room(30.0, 232.0) == 202.0; + assert tc_room(220.0, 232.0) == 0.0; + assert tc_cut(250.0, 202.0); + assert tc_cut(150.0, 202.0) == false; + assert tc_cut(250.0, TC_FREE) == false; +} diff --git a/specs/t27os/vt.t27 b/specs/t27os/vt.t27 new file mode 100644 index 0000000000..60700ce214 --- /dev/null +++ b/specs/t27os/vt.t27 @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/vt.t27 -- a minimal VT/ANSI terminal: bytes in, a grid of cells out. The T27 OS window feeds +; the output of a pane's PTY (pty_client.t27 READ) through vt_feed and draws the grid. +; Owner, 2026-10-19: agent CLIs run as real terminals; the window needs just enough of a terminal to show +; Claude Code, Codex and a shell: printable text, CR LF BS TAB, cursor moves, erase and scroll. Colour and +; style (SGR) are read and dropped: the screen is black and white. +; State is a u32 register file (ST_*) and the cells are one u32 codepoint each, row-major, cols * rows long; +; nothing else is kept, so a host loop owns two plain buffers and calls vt_feed per byte. +; Handled: printable ASCII and UTF-8 (decoded to a codepoint), CR, LF (scrolls at the bottom), BS, TAB (to +; the next multiple of 8), and CSI: A B C D (up, down, right, left), H f (position), G (column), d (row), +; J (erase display 0, 1, 2, 3), K (erase line 0, 1, 2). Every other CSI (m, h, l, r, ...), OSC strings +; (BEL or ESC \ ends them) and two-byte ESC sequences are consumed and dropped. A line that reaches the +; right edge wraps on the next printable (deferred wrap, as xterm does). + +module T27osVt; + +pub const ST_X : usize = 0; +pub const ST_Y : usize = 1; +pub const ST_COLS : usize = 2; +pub const ST_ROWS : usize = 3; +pub const ST_MODE : usize = 4; +pub const ST_P0 : usize = 5; +pub const ST_P1 : usize = 6; +pub const ST_NP : usize = 7; +pub const ST_UTF : usize = 8; +pub const ST_CP : usize = 9; +pub const ST_COUNT : usize = 10; + +pub const M_GROUND : u32 = 0; +pub const M_ESC : u32 = 1; +pub const M_CSI : u32 = 2; +pub const M_OSC : u32 = 3; +pub const M_OSC_ESC : u32 = 4; + +pub const BLANK : u32 = 32; +pub const REPLACEMENT : u32 = 65533; +pub const PARAM_MAX : u32 = 9999; + +pub fn vt_init(st: []u32, cells: []u32, cols: u32, rows: u32) -> bool { + if (cols == 0 or rows == 0 or cells.len < cols * rows or st.len < ST_COUNT) { return false; } + var i : usize = 0; + while (i < ST_COUNT) { st[i] = 0; i = i + 1; } + st[ST_COLS] = cols; + st[ST_ROWS] = rows; + var c : usize = 0; + while (c < cols * rows) { cells[c] = BLANK; c = c + 1; } + return true; +} + +pub fn vt_cell(st: []const u32, cells: []const u32, x: u32, y: u32) -> u32 { + return cells[y * st[ST_COLS] + x]; +} + +fn blank_range(cells: []u32, from: u32, to: u32) -> bool { + var i : u32 = from; + while (i < to) { cells[i] = BLANK; i = i + 1; } + return true; +} + +fn scroll_up(st: []u32, cells: []u32) -> bool { + const cols : u32 = st[ST_COLS]; + const rows : u32 = st[ST_ROWS]; + var i : u32 = 0; + while (i < cols * (rows - 1)) { + cells[i] = cells[i + cols]; + i = i + 1; + } + return blank_range(cells, cols * (rows - 1), cols * rows); +} + +fn line_feed(st: []u32, cells: []u32) -> bool { + if (st[ST_Y] + 1 >= st[ST_ROWS]) { + return scroll_up(st, cells); + } + st[ST_Y] = st[ST_Y] + 1; + return true; +} + +fn put_cp(st: []u32, cells: []u32, cp: u32) -> bool { + if (st[ST_X] >= st[ST_COLS]) { + st[ST_X] = 0; + line_feed(st, cells); + } + cells[st[ST_Y] * st[ST_COLS] + st[ST_X]] = cp; + st[ST_X] = st[ST_X] + 1; + return true; +} + +fn one_if_zero(v: u32) -> u32 { + if (v == 0) { return 1; } + return v; +} + +fn clamp_to(v: u32, max_excl: u32) -> u32 { + if (v >= max_excl) { return max_excl - 1; } + return v; +} + +fn erase_display(st: []u32, cells: []u32, how: u32) -> bool { + const cols : u32 = st[ST_COLS]; + const at : u32 = st[ST_Y] * cols + clamp_to(st[ST_X], cols); + if (how == 0) { return blank_range(cells, at, cols * st[ST_ROWS]); } + if (how == 1) { return blank_range(cells, 0, at + 1); } + return blank_range(cells, 0, cols * st[ST_ROWS]); +} + +fn erase_line(st: []u32, cells: []u32, how: u32) -> bool { + const cols : u32 = st[ST_COLS]; + const row : u32 = st[ST_Y] * cols; + const x : u32 = clamp_to(st[ST_X], cols); + if (how == 0) { return blank_range(cells, row + x, row + cols); } + if (how == 1) { return blank_range(cells, row, row + x + 1); } + return blank_range(cells, row, row + cols); +} + +fn csi_final(st: []u32, cells: []u32, b: u8) -> bool { + const cols : u32 = st[ST_COLS]; + const rows : u32 = st[ST_ROWS]; + const p0 : u32 = st[ST_P0]; + const p1 : u32 = st[ST_P1]; + const x : u32 = clamp_to(st[ST_X], cols); + if (b == 65) { + // A: up + const d : u32 = one_if_zero(p0); + if (d > st[ST_Y]) { st[ST_Y] = 0; } else { st[ST_Y] = st[ST_Y] - d; } + } else if (b == 66) { + st[ST_Y] = clamp_to(st[ST_Y] + one_if_zero(p0), rows); + } else if (b == 67) { + st[ST_X] = clamp_to(x + one_if_zero(p0), cols); + } else if (b == 68) { + const d : u32 = one_if_zero(p0); + if (d > x) { st[ST_X] = 0; } else { st[ST_X] = x - d; } + } else if (b == 72 or b == 102) { + st[ST_Y] = clamp_to(one_if_zero(p0) - 1, rows); + st[ST_X] = clamp_to(one_if_zero(p1) - 1, cols); + } else if (b == 71) { + st[ST_X] = clamp_to(one_if_zero(p0) - 1, cols); + } else if (b == 100) { + st[ST_Y] = clamp_to(one_if_zero(p0) - 1, rows); + } else if (b == 74) { + erase_display(st, cells, p0); + } else if (b == 75) { + erase_line(st, cells, p0); + } + st[ST_MODE] = M_GROUND; + return true; +} + +fn utf8_byte(st: []u32, cells: []u32, b: u8) -> bool { + const v : u32 = @as(u32, b); + if (v >= 128 and v < 192) { + if (st[ST_UTF] == 0) { return true; } + st[ST_CP] = st[ST_CP] * 64 + (v & 63); + st[ST_UTF] = st[ST_UTF] - 1; + if (st[ST_UTF] == 0) { put_cp(st, cells, st[ST_CP]); } + return true; + } + if (st[ST_UTF] != 0) { + st[ST_UTF] = 0; + put_cp(st, cells, REPLACEMENT); + } + if (v >= 192 and v < 224) { st[ST_UTF] = 1; st[ST_CP] = v & 31; return true; } + if (v >= 224 and v < 240) { st[ST_UTF] = 2; st[ST_CP] = v & 15; return true; } + if (v >= 240 and v < 248) { st[ST_UTF] = 3; st[ST_CP] = v & 7; return true; } + return put_cp(st, cells, REPLACEMENT); +} + +fn ground_byte(st: []u32, cells: []u32, b: u8) -> bool { + if (b == 27) { st[ST_MODE] = M_ESC; return true; } + if (b == 13) { st[ST_X] = 0; return true; } + if (b == 10 or b == 11 or b == 12) { return line_feed(st, cells); } + if (b == 8) { + if (st[ST_X] > 0) { st[ST_X] = clamp_to(st[ST_X], st[ST_COLS]) - 1; } + return true; + } + if (b == 9) { + st[ST_X] = clamp_to((st[ST_X] / 8 + 1) * 8, st[ST_COLS]); + return true; + } + if (b < 32 or b == 127) { return true; } + if (b >= 128) { return utf8_byte(st, cells, b); } + if (st[ST_UTF] != 0) { st[ST_UTF] = 0; put_cp(st, cells, REPLACEMENT); } + return put_cp(st, cells, @as(u32, b)); +} + +fn csi_byte(st: []u32, cells: []u32, b: u8) -> bool { + if (b >= 48 and b <= 57) { + var v : u32 = @as(u32, b - 48); + if (st[ST_NP] == 0) { + st[ST_P0] = st[ST_P0] * 10 + v; + if (st[ST_P0] > PARAM_MAX) { st[ST_P0] = PARAM_MAX; } + } else if (st[ST_NP] == 1) { + st[ST_P1] = st[ST_P1] * 10 + v; + if (st[ST_P1] > PARAM_MAX) { st[ST_P1] = PARAM_MAX; } + } + return true; + } + if (b == 59) { + st[ST_NP] = st[ST_NP] + 1; + return true; + } + if (b >= 64 and b <= 126) { return csi_final(st, cells, b); } + if (b == 27) { st[ST_MODE] = M_ESC; } + return true; +} + +/// Feed one byte of PTY output to the terminal. +pub fn vt_feed(st: []u32, cells: []u32, b: u8) -> bool { + const mode : u32 = st[ST_MODE]; + if (mode == M_GROUND) { return ground_byte(st, cells, b); } + if (mode == M_ESC) { + if (b == 91) { + st[ST_MODE] = M_CSI; + st[ST_P0] = 0; + st[ST_P1] = 0; + st[ST_NP] = 0; + } else if (b == 93) { + st[ST_MODE] = M_OSC; + } else if (b == 27) { + st[ST_MODE] = M_ESC; + } else { + st[ST_MODE] = M_GROUND; + } + return true; + } + if (mode == M_CSI) { return csi_byte(st, cells, b); } + if (mode == M_OSC) { + if (b == 7) { st[ST_MODE] = M_GROUND; } + if (b == 27) { st[ST_MODE] = M_OSC_ESC; } + return true; + } + st[ST_MODE] = M_GROUND; + return true; +} + +/// Feed a run of bytes. +pub fn vt_feed_all(st: []u32, cells: []u32, src: []const u8) -> bool { + var i : usize = 0; + while (i < src.len) { + vt_feed(st, cells, src[i]); + i = i + 1; + } + return true; +} + +/// Test helper: feed text in which "^" stands for ESC, "%" for CR, "_" for BS and "@" for BEL, since a +/// string literal here has no escapes for them. +fn fed(st: []u32, cells: []u32, src: []const u8) -> bool { + var i : usize = 0; + while (i < src.len) { + var b : u8 = src[i]; + if (b == 94) { b = 27; } + if (b == 37) { b = 13; } + if (b == 95) { b = 8; } + if (b == 64) { b = 7; } + vt_feed(st, cells, b); + i = i + 1; + } + return true; +} + +test text_lands_on_the_grid_and_wraps { + var st : [10]u32 = undefined; + var cells : [40]u32 = undefined; + assert vt_init(st[0..10], cells[0..40], 8, 5); + fed(st[0..10], cells[0..40], "hello"); + assert vt_cell(st[0..10], cells[0..40], 0, 0) == 104; + assert vt_cell(st[0..10], cells[0..40], 4, 0) == 111; + assert st[ST_X] == 5; + fed(st[0..10], cells[0..40], "world!"); + // 11 characters on an 8-column grid: the last 3 wrap onto row 1 + assert vt_cell(st[0..10], cells[0..40], 7, 0) == 114; + assert vt_cell(st[0..10], cells[0..40], 0, 1) == 108; + assert vt_cell(st[0..10], cells[0..40], 2, 1) == 33; + assert st[ST_Y] == 1 and st[ST_X] == 3; +} + +test control_characters_move_the_cursor { + var st : [10]u32 = undefined; + var cells : [40]u32 = undefined; + assert vt_init(st[0..10], cells[0..40], 20, 2); + fed(st[0..10], cells[0..40], "ab%\ncd"); + assert vt_cell(st[0..10], cells[0..40], 0, 1) == 99; + fed(st[0..10], cells[0..40], "_X"); + assert vt_cell(st[0..10], cells[0..40], 1, 1) == 88; + fed(st[0..10], cells[0..40], "%\ty"); + assert vt_cell(st[0..10], cells[0..40], 8, 1) == 121; + assert st[ST_X] == 9; +} + +test the_screen_scrolls_at_the_bottom { + var st : [10]u32 = undefined; + var cells : [30]u32 = undefined; + assert vt_init(st[0..10], cells[0..30], 10, 3); + fed(st[0..10], cells[0..30], "one%\ntwo%\nthree%\nfour"); + assert vt_cell(st[0..10], cells[0..30], 0, 0) == 116; + assert vt_cell(st[0..10], cells[0..30], 1, 0) == 119; + assert vt_cell(st[0..10], cells[0..30], 0, 1) == 116; + assert vt_cell(st[0..10], cells[0..30], 2, 1) == 114; + assert vt_cell(st[0..10], cells[0..30], 0, 2) == 102; + assert vt_cell(st[0..10], cells[0..30], 5, 2) == 32; + assert st[ST_Y] == 2; +} + +test csi_moves_and_positions_the_cursor { + var st : [10]u32 = undefined; + var cells : [200]u32 = undefined; + assert vt_init(st[0..10], cells[0..200], 20, 10); + fed(st[0..10], cells[0..200], "^[5;7Hx"); + assert vt_cell(st[0..10], cells[0..200], 6, 4) == 120; + fed(st[0..10], cells[0..200], "^[2A^[3Dy"); + assert vt_cell(st[0..10], cells[0..200], 4, 2) == 121; + fed(st[0..10], cells[0..200], "^[B^[Cz"); + assert vt_cell(st[0..10], cells[0..200], 6, 3) == 122; + fed(st[0..10], cells[0..200], "^[H"); + assert st[ST_X] == 0 and st[ST_Y] == 0; + fed(st[0..10], cells[0..200], "^[99;99H"); + assert st[ST_X] == 19 and st[ST_Y] == 9; + fed(st[0..10], cells[0..200], "^[9999A^[9999D"); + assert st[ST_X] == 0 and st[ST_Y] == 0; + fed(st[0..10], cells[0..200], "^[12G^[4d"); + assert st[ST_X] == 11 and st[ST_Y] == 3; +} + +test erase_clears_by_the_cursor { + var st : [10]u32 = undefined; + var cells : [60]u32 = undefined; + assert vt_init(st[0..10], cells[0..60], 10, 6); + var r : u32 = 0; + while (r < 6) { + fed(st[0..10], cells[0..60], "aaaaaaaaaa"); + r = r + 1; + } + fed(st[0..10], cells[0..60], "^[3;5H^[K"); + assert vt_cell(st[0..10], cells[0..60], 3, 2) == 97; + assert vt_cell(st[0..10], cells[0..60], 4, 2) == 32; + assert vt_cell(st[0..10], cells[0..60], 9, 2) == 32; + fed(st[0..10], cells[0..60], "^[1K"); + assert vt_cell(st[0..10], cells[0..60], 0, 2) == 32; + assert vt_cell(st[0..10], cells[0..60], 4, 2) == 32; + fed(st[0..10], cells[0..60], "^[4;1H^[J"); + assert vt_cell(st[0..10], cells[0..60], 0, 3) == 32; + assert vt_cell(st[0..10], cells[0..60], 9, 5) == 32; + assert vt_cell(st[0..10], cells[0..60], 9, 1) == 97; + fed(st[0..10], cells[0..60], "^[2J"); + assert vt_cell(st[0..10], cells[0..60], 9, 1) == 32; +} + +test style_and_titles_are_dropped { + var st : [10]u32 = undefined; + var cells : [40]u32 = undefined; + assert vt_init(st[0..10], cells[0..40], 20, 2); + fed(st[0..10], cells[0..40], "^[1;31mred^[0m ^]0;a title@ok^]2;x^\\!"); + assert vt_cell(st[0..10], cells[0..40], 0, 0) == 114; + assert vt_cell(st[0..10], cells[0..40], 2, 0) == 100; + assert vt_cell(st[0..10], cells[0..40], 4, 0) == 111; + assert vt_cell(st[0..10], cells[0..40], 6, 0) == 33; + assert st[ST_X] == 7; + assert st[ST_MODE] == M_GROUND; + fed(st[0..10], cells[0..40], "^[?25l^[?2004h^c"); + assert st[ST_X] == 7 and st[ST_MODE] == M_GROUND; +} + +test utf8_is_decoded_to_codepoints { + var st : [10]u32 = undefined; + var cells : [40]u32 = undefined; + assert vt_init(st[0..10], cells[0..40], 20, 2); + // e-acute (2 bytes), euro (3 bytes), a box-drawing corner (3 bytes), a rocket emoji (4 bytes) + var a : [12]u8 = undefined; + a[0] = 195; a[1] = 169; + a[2] = 226; a[3] = 130; a[4] = 172; + a[5] = 226; a[6] = 149; a[7] = 173; + a[8] = 240; a[9] = 159; a[10] = 154; a[11] = 128; + vt_feed_all(st[0..10], cells[0..40], a[0..12]); + assert vt_cell(st[0..10], cells[0..40], 0, 0) == 233; + assert vt_cell(st[0..10], cells[0..40], 1, 0) == 8364; + assert vt_cell(st[0..10], cells[0..40], 2, 0) == 9581; + assert vt_cell(st[0..10], cells[0..40], 3, 0) == 128640; + assert st[ST_X] == 4; + // a lead byte followed by ASCII is a broken sequence: one replacement, then the ASCII + a[0] = 226; a[1] = 122; + vt_feed_all(st[0..10], cells[0..40], a[0..2]); + assert vt_cell(st[0..10], cells[0..40], 4, 0) == REPLACEMENT; + assert vt_cell(st[0..10], cells[0..40], 5, 0) == 122; + // a lone continuation byte is dropped + a[0] = 169; + vt_feed_all(st[0..10], cells[0..40], a[0..1]); + assert st[ST_X] == 6; +} + +test a_tiny_grid_cannot_be_overrun { + var st : [10]u32 = undefined; + var cells : [4]u32 = undefined; + assert vt_init(st[0..10], cells[0..4], 2, 2); + var i : u32 = 0; + while (i < 50) { + fed(st[0..10], cells[0..4], "ab^[9B^[9C_\n^[J"); + i = i + 1; + } + assert st[ST_X] < 3 and st[ST_Y] < 2; + assert vt_init(st[0..10], cells[0..4], 3, 2) == false; + assert vt_init(st[0..10], cells[0..4], 0, 2) == false; +} diff --git a/specs/t27os/window_keep.t27 b/specs/t27os/window_keep.t27 new file mode 100644 index 0000000000..70d73f1176 --- /dev/null +++ b/specs/t27os/window_keep.t27 @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/window_keep.t27 -- the T27 OS window keeps what it shows across launches. +; Owner bug, 2026-10-19 (epic #10782): after a reboot the open screen, spaces, task and panes were gone. +; The window saved them only on a hot reload, under a key the next process cleared, so an ordinary +; launch always started at home. Now the loop folds the kept state into one key every frame and writes +; it to the user defaults once the key has held still for WK_SETTLE frames (a debounce: a drag or a +; typed name is not written thirty times a second). Every launch reads it back. A clean start is an +; explicit act: hold Option while the window opens, and it starts at home and forgets the kept state. +; The window frame is not here: AppKit keeps it under the autosave name "T27OS". + +module T27osWindowKeep; + +use t27os::frame_gate::fg_mix; + +/// Frames the kept state must hold still before it is written (0.5 s at 30 fps). +pub const WK_SETTLE : u32 = 15; +/// NSEventModifierFlagOption, 1 << 19. +pub const WK_OPTION : u64 = 524288; + +/// The flags word: collapsed sidebar 1, dashboard 2, quick panel 4, god view 8, doctor panel 16, +/// auto-open off 32 (the default, on, is 0 so an empty defaults store reads as the defaults). +pub fn wk_flags(collapsed: bool, dash: bool, qp: bool, god: bool, dp: bool, auto_open: bool) -> u64 { + var fl : u64 = 0; + if (collapsed) { fl = fl + 1; } + if (dash) { fl = fl + 2; } + if (qp) { fl = fl + 4; } + if (god) { fl = fl + 8; } + if (dp) { fl = fl + 16; } + if (!auto_open) { fl = fl + 32; } + return fl; +} + +/// One bit of the flags word: bit 0..5 as 1, 2, 4, 8, 16, 32. +pub fn wk_bit(fl: u64, bit: u64) -> bool { + return fl / bit % 2 == 1; +} + +/// The key of the kept state: it changes when any kept value changes. +pub fn wk_key(state: u32, shown: u32, task: u32, space: u32, ss: u64, sp: u32, cur: u32, fl: u64, evn: u32) -> u64 { + var h : u64 = fg_mix(0, @as(u64, state)); + h = fg_mix(h, @as(u64, shown)); + h = fg_mix(h, @as(u64, task)); + h = fg_mix(h, @as(u64, space)); + h = fg_mix(h, ss); + h = fg_mix(h, @as(u64, sp)); + h = fg_mix(h, @as(u64, cur)); + h = fg_mix(h, fl); + return fg_mix(h, @as(u64, evn)); +} + +/// Frames the key has held still, after this frame. +pub fn wk_still(still: u32, key: u64, last: u64) -> u32 { + if (key != last) { return 0; } + if (still >= 9999) { return still; } + return still + 1; +} + +/// Write now? The key differs from the one written and has held still for WK_SETTLE frames. +pub fn wk_due(key: u64, saved: u64, still: u32) -> bool { + return key != saved and still >= WK_SETTLE; +} + +/// Restore at launch? When something was kept and Option is not held. +pub fn wk_restore(kept: u64, mods: u64) -> bool { + if (wk_bit(mods, WK_OPTION)) { return false; } + return kept == 1; +} + +test the_flags_word_round_trips { + const fl : u64 = wk_flags(true, false, true, false, true, false); + assert fl == 1 + 4 + 16 + 32; + assert wk_bit(fl, 1); + assert wk_bit(fl, 2) == false; + assert wk_bit(fl, 4); + assert wk_bit(fl, 8) == false; + assert wk_bit(fl, 16); + assert wk_bit(fl, 32); + // the defaults are all zero, so a launch with nothing kept reads as the defaults + assert wk_flags(false, false, false, false, false, true) == 0; +} + +test any_kept_value_changes_the_key { + const k : u64 = wk_key(1, 2, 3, 0, 77, 5, 1, 0, 4); + assert k == wk_key(1, 2, 3, 0, 77, 5, 1, 0, 4); + assert k != wk_key(2, 2, 3, 0, 77, 5, 1, 0, 4); + assert k != wk_key(1, 2, 3, 1, 77, 5, 1, 0, 4); + assert k != wk_key(1, 2, 3, 0, 78, 5, 1, 0, 4); + assert k != wk_key(1, 2, 3, 0, 77, 5, 2, 0, 4); + assert k != wk_key(1, 2, 3, 0, 77, 5, 1, 1, 4); + assert k != wk_key(1, 2, 3, 0, 77, 5, 1, 0, 5); +} + +test a_change_is_written_once_it_settles { + // a new key: the count restarts, nothing is written yet + assert wk_still(40, 9, 8) == 0; + assert wk_due(9, 8, 0) == false; + assert wk_due(9, 8, WK_SETTLE - 1) == false; + assert wk_due(9, 8, WK_SETTLE); + // once written, a still key is not written again + assert wk_due(9, 9, 500) == false; + assert wk_still(3, 9, 9) == 4; + assert wk_still(9999, 9, 9) == 9999; +} + +test option_at_launch_is_a_clean_start { + assert wk_restore(1, 0); + assert wk_restore(0, 0) == false; + assert wk_restore(1, WK_OPTION) == false; + // shift (1 << 17) and command (1 << 20) do not count + assert wk_restore(1, 131072 + 1048576); +} diff --git a/specs/t27os/windows.t27 b/specs/t27os/windows.t27 new file mode 100644 index 0000000000..2195345770 --- /dev/null +++ b/specs/t27os/windows.t27 @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27os/windows.t27 -- the window model of T27 OS: spaces, the agent sessions in them, and the +; sidebar that lists both, after herdr. +; Owner, 2026-10-19: implement herdr's window logic, but with three groups -- queen, spaces, agents. +; herdr's sidebar has a "spaces" group (repository, branch, a dot coloured by its agents) and an +; "agents" group (pane name, "state . agent"). T27 OS keeps that and puts the Queen above both, because +; the Queen is the one that prompts every other agent: +; QUEEN the orchestrator row, always there; +; SPACES one row per workspace: a state dot, the name, the branch in dim text; +; AGENTS the sessions of the selected space: the agent mark, its name, then its state and space. +; Sessions are a list of up to seven chats, one byte each packed in a u64 (agent and space), so the whole +; model is one integer that the hot reload saves and restores. An agent may run several chats at once +; (owner, 2026-10-19: "a + button for a new chat, so several Claude Codes can run, and deletion from the +; side menu"). A tap on a honeycomb cell selects that agent's first chat in the space, opening one if +; there is none; "+" in the header always opens a new chat; the x on a hovered AGENTS row closes it. +; Black and white only (FR-006): the state is a dot shape, not a colour -- +; stopped a faint ring, idle a ring, working a filled dot, blocked a filled dot in a ring (needs you), +; done a dim filled dot. +; Seed data, not yet read from disk: the first three SPACE_NAMES and SPACE_BRANCHES are the repositories this +; app is built from; "+" in the SPACES label line opens one of three more slots (no repository bound yet), and +; the x on a hovered space row closes a space and its chats. The host side (follow-up, see agents.t27) will +; read spaces from git worktrees and states from the terminal owner; until then every session shows +; S_STOPPED, which is true -- no agent process is started yet. + +module T27osWindows; + +use t27os::agents::AGENT_COUNT; +use t27os::agents::AGENT_QUEEN; +use t27os::agents::S_STOPPED; +use t27os::agents::S_WORKING; +use t27os::agents::S_BLOCKED; +use t27os::agents::S_IDLE; +use t27os::agents::S_DONE; + +/// Space slots. The first three are the repositories this app is built from; "+" in the SPACES label line +/// opens the next closed slot, and the x on a hovered space row closes one. SPACES_SEED is the open set at first +/// start, one bit per slot. +pub const SPACE_COUNT : u32 = 6; +pub const SPACE_NAMES : str = "t27,trinity,t27os,space 4,space 5,space 6"; +pub const SPACE_BRANCHES : str = "master,main,feat/t27os-petal-menu,no repository,no repository,no repository"; +pub const SPACES_SEED : u32 = 7; +/// The foot of the sidebar: one line, OPEN SOURCE T27, that opens the repository so others can build +/// features with us (owner, 2026-10-19; renamed from I LOVE OPEN SOURCE the same day). Black and white like the rest; FOOT_H is its strip above the bottom edge. +pub const REPO_LINK_TEXT : str = "OPEN SOURCE T27"; +pub const REPO_URL : str = "https://github.com/gHashTag/t27"; +pub const FOOT_H : f64 = 34.0; +/// The words for S_STOPPED .. S_DONE, in state order. +pub const STATE_WORDS : str = "stopped,working,blocked,idle,done"; +pub const SPACE_NONE : u32 = 4294967295; + +/// Dot shapes for a state. +pub const DOT_FAINT : u32 = 0; +pub const DOT_RING : u32 = 1; +pub const DOT_FILLED : u32 = 2; +pub const DOT_ALERT : u32 = 3; +pub const DOT_DIM : u32 = 4; + +/// Sidebar geometry from the top of the workspace (points). +pub const LABEL_BASE : f64 = 22.0; +pub const ROWS_OFF : f64 = 30.0; +pub const ROW_H : f64 = 28.0; +pub const ROW_STEP : f64 = 30.0; +pub const AGENT_ROW_H : f64 = 38.0; +pub const AGENT_STEP : f64 = 40.0; +pub const GROUP_GAP : f64 = 14.0; +pub const GROUP_QUEEN : u32 = 0; +pub const GROUP_SPACES : u32 = 1; +pub const GROUP_AGENTS : u32 = 2; +pub const ROW_NONE : u32 = 4294967295; + +/// Up to SESSION_MAX sessions, one byte each in a u64, kept in opening order with no holes: byte i is 0 for +/// no session, else 1 + agent * 8 + space. Seven keeps the shift in remove_session inside 64 bits; the 540-pt +/// window shows five of them above the sidebar foot (glass_window.t27 layout test), scrolling is not built yet. +pub const SESSION_MAX : u32 = 7; +pub const SESSION_NONE : u32 = 4294967295; +/// The agent a "+" opens when no agent session is selected: Claude Code, cell 0. +pub const NEW_CHAT_DEFAULT : u32 = 0; + +fn pow256(k: u32) -> u64 { + var p : u64 = 1; + var i : u32 = 0; + while (i < k) { + p = p * 256; + i = i + 1; + } + return p; +} + +fn slot(ss: u64, i: u32) -> u64 { + if (i >= SESSION_MAX) { return 0; } + return ss / pow256(i) % 256; +} + +/// How many sessions are open. +pub fn session_count(ss: u64) -> u32 { + var n : u32 = 0; + while (n < SESSION_MAX and slot(ss, n) != 0) { n = n + 1; } + return n; +} + +/// The agent of session i (AGENT_COUNT when there is none). +pub fn session_agent(ss: u64, i: u32) -> u32 { + const b : u64 = slot(ss, i); + if (b == 0) { return AGENT_COUNT; } + return @as(u32, @intCast((b - 1) / 8)); +} + +/// The space session i lives in, SPACE_NONE when there is none. +pub fn session_space(ss: u64, i: u32) -> u32 { + const b : u64 = slot(ss, i); + if (b == 0) { return SPACE_NONE; } + return @as(u32, @intCast((b - 1) % 8)); +} + +/// `ss` with one more session of agent k in space s, at index session_count(ss); unchanged when full or out +/// of range. An agent may run any number of sessions -- several Claude Codes at once. +pub fn add_session(ss: u64, k: u32, s: u32) -> u64 { + const n : u32 = session_count(ss); + if (k >= AGENT_COUNT or s >= SPACE_COUNT or n >= SESSION_MAX) { return ss; } + return ss + @as(u64, 1 + k * 8 + s) * pow256(n); +} + +/// `ss` with session i closed; the later ones move down one index. +pub fn remove_session(ss: u64, i: u32) -> u64 { + if (i >= session_count(ss)) { return ss; } + const p : u64 = pow256(i); + return ss % p + ss / pow256(i + 1) * p; +} + +/// The selected index after session i is closed: none if it was the one closed, one lower if it was after. +pub fn after_remove(cur: u32, i: u32) -> u32 { + if (cur == SESSION_NONE or cur < i) { return cur; } + if (cur == i) { return SESSION_NONE; } + return cur - 1; +} + +/// How many sessions space s holds. +pub fn sessions_in(ss: u64, s: u32) -> u32 { + var n : u32 = 0; + var i : u32 = 0; + while (i < SESSION_MAX) { + if (session_space(ss, i) == s) { n = n + 1; } + i = i + 1; + } + return n; +} + +/// The session on row r of space s's AGENTS list, SESSION_NONE past the last row. +pub fn session_at_row(ss: u64, s: u32, r: u32) -> u32 { + var n : u32 = 0; + var i : u32 = 0; + while (i < SESSION_MAX) { + if (session_space(ss, i) == s) { + if (n == r) { return i; } + n = n + 1; + } + i = i + 1; + } + return SESSION_NONE; +} + +/// The first session of agent k in space s, SESSION_NONE when it has none there. +pub fn first_of(ss: u64, k: u32, s: u32) -> u32 { + var i : u32 = 0; + while (i < SESSION_MAX) { + if (session_agent(ss, i) == k and session_space(ss, i) == s) { return i; } + i = i + 1; + } + return SESSION_NONE; +} + +/// 1 for an agent's first chat in its space, 2 for the second, and so on: the row shows "Claude Code 2". +pub fn chat_number(ss: u64, i: u32) -> u32 { + var n : u32 = 1; + var j : u32 = 0; + while (j < i) { + if (session_agent(ss, j) == session_agent(ss, i) and session_space(ss, j) == session_space(ss, i)) { n = n + 1; } + j = j + 1; + } + return n; +} + +/// The agent "+" opens: the selected agent, else Claude Code (the Queen does not run in a chat session). +pub fn new_chat_agent(task: u32) -> u32 { + if (task < AGENT_COUNT and task != AGENT_QUEEN) { return task; } + return NEW_CHAT_DEFAULT; +} + +fn slot_bit(k: u32) -> u32 { + var p : u32 = 1; + var i : u32 = 0; + while (i < k) { + p = p * 2; + i = i + 1; + } + return p; +} + +/// Whether space slot s is open in the set sp (one bit per slot). +pub fn space_is_open(sp: u32, s: u32) -> bool { + if (s >= SPACE_COUNT) { return false; } + return sp / slot_bit(s) % 2 == 1; +} + +/// How many spaces the SPACES group shows. +pub fn spaces_shown(sp: u32) -> u32 { + var n : u32 = 0; + var s : u32 = 0; + while (s < SPACE_COUNT) { + if (space_is_open(sp, s)) { n = n + 1; } + s = s + 1; + } + return n; +} + +/// The space slot on row r of the SPACES group, SPACE_NONE past the last row. +pub fn space_at_row(sp: u32, r: u32) -> u32 { + var n : u32 = 0; + var s : u32 = 0; + while (s < SPACE_COUNT) { + if (space_is_open(sp, s)) { + if (n == r) { return s; } + n = n + 1; + } + s = s + 1; + } + return SPACE_NONE; +} + +/// The row space slot s sits on, ROW_NONE when it is closed. +pub fn row_of_space(sp: u32, s: u32) -> u32 { + if (space_is_open(sp, s) == false) { return ROW_NONE; } + var n : u32 = 0; + var j : u32 = 0; + while (j < s) { + if (space_is_open(sp, j)) { n = n + 1; } + j = j + 1; + } + return n; +} + +/// The slot "+" opens: the first closed one, SPACE_NONE when all are open. +pub fn new_space(sp: u32) -> u32 { + var s : u32 = 0; + while (s < SPACE_COUNT) { + if (space_is_open(sp, s) == false) { return s; } + s = s + 1; + } + return SPACE_NONE; +} + +/// `sp` with slot s opened; unchanged when it is open or out of range. +pub fn add_space(sp: u32, s: u32) -> u32 { + if (s >= SPACE_COUNT or space_is_open(sp, s)) { return sp; } + return sp + slot_bit(s); +} + +/// `sp` with slot s closed; the last open space stays, so the window always has one. +pub fn remove_space(sp: u32, s: u32) -> u32 { + if (space_is_open(sp, s) == false or spaces_shown(sp) <= 1) { return sp; } + return sp - slot_bit(s); +} + +/// `ss` with every session of space s closed, the others kept in order. +pub fn drop_sessions_in(ss: u64, s: u32) -> u64 { + var out : u64 = ss; + var i : u32 = SESSION_MAX; + while (i > 0) { + i = i - 1; + if (session_space(out, i) == s) { out = remove_session(out, i); } + } + return out; +} + +/// The selected session after drop_sessions_in(ss, s): none if it was in s, else its new index. +pub fn after_drop(cur: u32, ss: u64, s: u32) -> u32 { + if (cur == SESSION_NONE or cur >= session_count(ss)) { return SESSION_NONE; } + if (session_space(ss, cur) == s) { return SESSION_NONE; } + var n : u32 = 0; + var i : u32 = 0; + while (i < cur) { + if (session_space(ss, i) != s) { n = n + 1; } + i = i + 1; + } + return n; +} + +/// The dot a session in state st shows. +pub fn dot_for(st: u32) -> u32 { + if (st == S_WORKING) { return DOT_FILLED; } + if (st == S_BLOCKED) { return DOT_ALERT; } + if (st == S_IDLE) { return DOT_RING; } + if (st == S_DONE) { return DOT_DIM; } + return DOT_FAINT; +} + +/// How loud a state is in its space's dot: the one waiting on you wins. +pub fn loudness(st: u32) -> u32 { + if (st == S_BLOCKED) { return 4; } + if (st == S_WORKING) { return 3; } + if (st == S_IDLE) { return 2; } + if (st == S_DONE) { return 1; } + return 0; +} + +/// The louder of two states. +pub fn louder(a: u32, b: u32) -> u32 { + if (loudness(b) > loudness(a)) { return b; } + return a; +} + +/// Top of a sidebar group, from the workspace top t, with n space rows above the AGENTS group. +pub fn group_top(t: f64, g: u32, n: u32) -> f64 { + const spaces : f64 = t + ROWS_OFF + ROW_H + GROUP_GAP; + if (g == GROUP_QUEEN) { return t; } + if (g == GROUP_SPACES) { return spaces; } + return spaces + ROWS_OFF + @as(f64, @floatFromInt(n)) * ROW_STEP - (ROW_STEP - ROW_H) + GROUP_GAP; +} + +/// Baseline of a group's label. +pub fn label_base(t: f64, g: u32, n: u32) -> f64 { + return group_top(t, g, n) + LABEL_BASE; +} + +/// Top of row i in a group. +pub fn row_top(t: f64, g: u32, n: u32, i: u32) -> f64 { + var step : f64 = ROW_STEP; + if (g == GROUP_AGENTS) { step = AGENT_STEP; } + return group_top(t, g, n) + ROWS_OFF + @as(f64, @floatFromInt(i)) * step; +} + +/// The row of group g under y, given `rows` rows in it; ROW_NONE between or past the rows. +pub fn row_at(t: f64, g: u32, n: u32, rows: u32, y: f64) -> u32 { + var h : f64 = ROW_H; + if (g == GROUP_AGENTS) { h = AGENT_ROW_H; } + var i : u32 = 0; + while (i < rows) { + const top : f64 = row_top(t, g, n, i); + if (y >= top and y <= top + h) { return i; } + i = i + 1; + } + return ROW_NONE; +} + +test several_chats_of_one_agent_open_and_close { + var ss : u64 = 0; + assert session_count(ss) == 0; + assert session_agent(ss, 0) == AGENT_COUNT; + // "+" three times with Claude Code selected, then Codex in space 2 + ss = add_session(ss, 0, 0); + ss = add_session(ss, 0, 0); + ss = add_session(ss, 5, 2); + ss = add_session(ss, 0, 0); + assert session_count(ss) == 4; + assert sessions_in(ss, 0) == 3; + assert sessions_in(ss, 2) == 1; + assert session_agent(ss, 2) == 5; + assert session_space(ss, 2) == 2; + assert chat_number(ss, 0) == 1; + assert chat_number(ss, 1) == 2; + assert chat_number(ss, 3) == 3; + assert session_at_row(ss, 0, 2) == 3; + assert session_at_row(ss, 0, 3) == SESSION_NONE; + assert first_of(ss, 5, 2) == 2; + assert first_of(ss, 5, 0) == SESSION_NONE; + // close the second Claude Code: the later sessions move down one index, in order + ss = remove_session(ss, 1); + assert session_count(ss) == 3; + assert session_agent(ss, 1) == 5; + assert session_agent(ss, 2) == 0; + assert chat_number(ss, 2) == 2; + // closing past the end, or an out-of-range agent or space, changes nothing + assert remove_session(ss, 3) == ss; + assert add_session(ss, AGENT_COUNT, 0) == ss; + assert add_session(ss, 1, SPACE_COUNT) == ss; +} + +test seven_sessions_fill_the_list_and_the_selection_follows_a_close { + var ss : u64 = 0; + var i : u32 = 0; + while (i < SESSION_MAX + 2) { + ss = add_session(ss, AGENT_COUNT - 1, SPACE_COUNT - 1); + i = i + 1; + } + assert session_count(ss) == SESSION_MAX; + assert session_agent(ss, SESSION_MAX - 1) == AGENT_COUNT - 1; + // the last slot closes without overflowing the shift + assert session_count(remove_session(ss, SESSION_MAX - 1)) == SESSION_MAX - 1; + assert session_count(remove_session(ss, 0)) == SESSION_MAX - 1; + assert after_remove(3, 3) == SESSION_NONE; + assert after_remove(4, 3) == 3; + assert after_remove(2, 3) == 2; + assert after_remove(SESSION_NONE, 0) == SESSION_NONE; + // "+" opens the selected agent, Claude Code when the Queen or nothing is selected + assert new_chat_agent(5) == 5; + assert new_chat_agent(AGENT_QUEEN) == NEW_CHAT_DEFAULT; + assert new_chat_agent(AGENT_COUNT) == NEW_CHAT_DEFAULT; +} + +test the_state_is_a_shape_and_the_blocked_one_wins { + assert dot_for(S_STOPPED) == DOT_FAINT; + assert dot_for(S_IDLE) == DOT_RING; + assert dot_for(S_WORKING) == DOT_FILLED; + assert dot_for(S_BLOCKED) == DOT_ALERT; + assert dot_for(S_DONE) == DOT_DIM; + assert louder(S_WORKING, S_BLOCKED) == S_BLOCKED; + assert louder(S_BLOCKED, S_IDLE) == S_BLOCKED; + assert louder(S_STOPPED, S_DONE) == S_DONE; +} + +test the_three_groups_stack_queen_spaces_agents { + const t : f64 = 35.0; + assert label_base(t, GROUP_QUEEN, 3) == 57.0; + assert row_top(t, GROUP_QUEEN, 3, 0) == 65.0; + // SPACES starts a gap below the Queen row + assert group_top(t, GROUP_SPACES, 3) == 65.0 + ROW_H + GROUP_GAP; + // AGENTS starts a gap below the last space row + assert group_top(t, GROUP_AGENTS, 3) == row_top(t, GROUP_SPACES, 3, 2) + ROW_H + GROUP_GAP; + assert row_at(t, GROUP_SPACES, 3, 3, row_top(t, GROUP_SPACES, 3, 1) + 1.0) == 1; + assert row_at(t, GROUP_SPACES, 3, 3, row_top(t, GROUP_SPACES, 3, 1) - 1.0) == ROW_NONE; + assert row_at(t, GROUP_AGENTS, 3, 2, row_top(t, GROUP_AGENTS, 3, 1) + 30.0) == 1; + assert row_at(t, GROUP_AGENTS, 3, 2, row_top(t, GROUP_AGENTS, 3, 2) + 1.0) == ROW_NONE; +} + +test a_plus_opens_a_space_and_the_x_closes_it_with_its_chats { + var sp : u32 = SPACES_SEED; + assert spaces_shown(sp) == 3; + assert space_at_row(sp, 2) == 2; + assert space_at_row(sp, 3) == SPACE_NONE; + // "+" opens slot 3, then slot 4 + assert new_space(sp) == 3; + sp = add_space(sp, new_space(sp)); + sp = add_space(sp, new_space(sp)); + assert spaces_shown(sp) == 5; + // close trinity: the rows below move up one + sp = remove_space(sp, 1); + assert space_is_open(sp, 1) == false; + assert space_at_row(sp, 1) == 2; + assert row_of_space(sp, 3) == 2; + assert row_of_space(sp, 1) == ROW_NONE; + // the next "+" reuses the closed slot + assert new_space(sp) == 1; + // all open: "+" has nothing to open, and adding twice changes nothing + var all : u32 = 0; + var s : u32 = 0; + while (s < SPACE_COUNT) { + all = add_space(all, s); + s = s + 1; + } + assert new_space(all) == SPACE_NONE; + assert add_space(all, 0) == all; + // the last space cannot be closed + assert remove_space(1, 0) == 1; + // closing a space closes its chats; the selection follows + var ss : u64 = 0; + ss = add_session(ss, 0, 0); + ss = add_session(ss, 1, 1); + ss = add_session(ss, 2, 0); + ss = add_session(ss, 3, 1); + ss = add_session(ss, 4, 5); + const left : u64 = drop_sessions_in(ss, 1); + assert session_count(left) == 3; + assert session_agent(left, 1) == 2; + assert session_space(left, 2) == 5; + assert after_drop(1, ss, 1) == SESSION_NONE; + assert after_drop(2, ss, 1) == 1; + assert after_drop(4, ss, 1) == 2; + assert after_drop(SESSION_NONE, ss, 1) == SESSION_NONE; +} + +test the_foot_strip_holds_the_link { + // the strip is taller than a space row, so the line under it is easy to hit + assert FOOT_H > ROW_H; + // the words are the owner's: OPEN SOURCE T27, three words, ending in the project's name + var gaps : u32 = 0; + var i : usize = 0; + while (i < REPO_LINK_TEXT.len) { + if (REPO_LINK_TEXT[i] == 32) { gaps = gaps + 1; } + i = i + 1; + } + assert gaps == 2; + assert REPO_LINK_TEXT[REPO_LINK_TEXT.len - 1] == 55; +}