diff --git a/dotfiles/fish/functions/a.fish b/dotfiles/fish/functions/a.fish new file mode 100644 index 0000000000..35218204b1 --- /dev/null +++ b/dotfiles/fish/functions/a.fish @@ -0,0 +1,3 @@ +function a --wraps=ac --description 'Shorthand for aesel pro (Easel as a general harness in this directory)' + ac pro $argv +end diff --git a/dotfiles/fish/functions/ar.fish b/dotfiles/fish/functions/ar.fish new file mode 100644 index 0000000000..bac76db9ed --- /dev/null +++ b/dotfiles/fish/functions/ar.fish @@ -0,0 +1,3 @@ +function ar --wraps=ac --description 'Shorthand for aesel history (pick a session to resume; --last for the most recent)' + ac history $argv +end diff --git a/easel/bin/easel b/easel/bin/easel index e9381bb3ac..3685a44f0e 100755 --- a/easel/bin/easel +++ b/easel/bin/easel @@ -124,6 +124,12 @@ case "${1:-}" in doctor exit 0 ;; + history) + # The way back into an earlier session: a list, a number, the old + # thread on the same engine in the same directory. `--last` skips the list. + shift + exec node "$PROJECT_DIR/src/history-cli.mjs" "$@" + ;; login|logout|whoami|publish) has_command node || fail "Node.js is required" exec node "$PROJECT_DIR/src/cli.mjs" "$@" diff --git a/easel/package.json b/easel/package.json index 9b8b6a69a9..688be02d13 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.18", + "version": "0.7.19", "private": true, "type": "module", "scripts": { diff --git a/easel/src/history-cli.mjs b/easel/src/history-cli.mjs new file mode 100644 index 0000000000..fb9419ab09 --- /dev/null +++ b/easel/src/history-cli.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// history — the sessions this machine has had, and a way back into one. +// +// `cr` reopens a Claude conversation and `cor` a Codex one; this is the same +// door for Easel. Every session wrote a transcript (transcript.mjs), and its +// engine event names the thread the engine was running, so resuming is a +// matter of reading that back and launching the interface with it: the same +// directory, the same engine and model, the same mode, the old thread. +// +// ac history list, then pick a number +// ac history --last straight into the most recent +// ac history 3 straight into the third on the list +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; +import { Transcript, defaultRoot } from "./transcript.mjs"; + +const EASEL = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "easel"); +const LIMIT = 20; + +// The last thread the engine reported is the one to resume; a session that +// switched engines mid-way resumes on the engine it ended on. +function lastEngine(sessionId, root) { + let found = null; + for (const event of Transcript.read(sessionId, root)) if (event.kind === "engine" && event.thread) found = event; + return found; +} + +function when(iso) { + const ms = Date.now() - Date.parse(iso); + const minutes = Math.round(ms / 60000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function tilde(path) { + const home = homedir(); + return path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export function sessions(root = defaultRoot()) { + return Transcript.list(root) + .map((meta) => ({ ...meta, engineEvent: lastEngine(meta.session_id, root) })) + .filter((meta) => meta.engineEvent && meta.cwd) + .slice(0, LIMIT); +} + +export function launchArguments(meta) { + const args = []; + if (meta.pro) args.push("pro"); + if (meta.private) args.push("--private"); + args.push("--cwd", meta.cwd, "--backend", meta.engineEvent.engine, "--resume", meta.engineEvent.thread); + if (meta.engineEvent.model) args.push("--model", meta.engineEvent.model); + return args; +} + +async function pick(list) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => rl.question("resume › ", resolve)); + rl.close(); + const index = Number.parseInt(answer, 10); + return Number.isInteger(index) && index >= 1 && index <= list.length ? list[index - 1] : null; +} + +async function main() { + const argv = process.argv.slice(2); + const list = sessions(); + if (!list.length) { + process.stderr.write("No sessions yet. `a` starts one.\n"); + process.exit(1); + } + let chosen = null; + const direct = argv.find((a) => /^\d+$/.test(a)); + if (argv.includes("--last")) chosen = list[0]; + else if (direct) chosen = list[Number(direct) - 1] || null; + else { + list.forEach((meta, index) => { + const subject = meta.private ? "private" : meta.subject || "(no subject yet)"; + const mode = meta.pro ? "pro" : "piece"; + process.stdout.write(`${String(index + 1).padStart(3)} ${when(meta.updated).padEnd(8)} ${mode.padEnd(5)} ${(meta.engineEvent.model || meta.engineEvent.engine).padEnd(18)} ${tilde(meta.cwd).padEnd(28)} ${subject.slice(0, 60)}\n`); + }); + chosen = await pick(list); + } + if (!chosen) { + process.stderr.write("Nothing chosen.\n"); + process.exit(1); + } + const result = spawnSync(EASEL, launchArguments(chosen), { stdio: "inherit" }); + process.exit(result.status ?? 1); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main(); diff --git a/easel/src/render.mjs b/easel/src/render.mjs index a3e7cdf330..05f4966aed 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -670,19 +670,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const account = state.account || ""; const model = state.model || state.providerSettings?.model || ""; const engine = state.providerSettings?.backend || ""; - const muted = (text) => (text ? paint(useColor, "muted", text) : ""); - const queuedInbox = (state.queued || []).filter((line) => line?.inbox).length; - const fact = { - handle: () => (account ? (account.startsWith("@") ? coloredHandle(account, state.handleColors, useColor) : muted(account)) : ""), - workspace: () => muted(clipText(state.workspace || "", Math.max(8, Math.floor(width / 3)))), - model: () => muted(model ? `${model}${engine && engine !== "claude" && !shape.status.includes("engine") ? ` · ${engine}` : ""}` : ""), - engine: () => muted(engine), - mode: () => muted(mode.toLowerCase()), - activity: () => muted(state.busy ? requestFeedback(state) : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : ""), - inbox: () => muted(queuedInbox ? `${queuedInbox} inbox queued` : ""), - }; - const facts = shape.status.map((name) => fact[name]?.() || "").filter(Boolean); - const statusLine = ` ${facts.join(muted(shape.separator))}`; + const statusLine = proStatus(state, width, useColor, shape).line; const rows = { gap: () => "", bar: () => bar, @@ -731,6 +719,43 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { .join("\n"); } +// The status line under the bar, and where each fact on it starts, so the +// frame can paint it and a click can find the model on it. +export function proStatus(state, width, useColor, shape = state.layout || {}) { + const status = shape.status || ["handle", "workspace", "model", "mode", "activity"]; + const separator = shape.separator ?? " · "; + const account = state.account || ""; + const model = state.model || state.providerSettings?.model || ""; + const engine = state.providerSettings?.backend || ""; + const queuedInbox = (state.queued || []).filter((line) => line?.inbox).length; + const plain = { + handle: account, + workspace: clipText(state.workspace || "", Math.max(8, Math.floor(width / 3))), + model: model ? `${model}${engine && engine !== "claude" && !status.includes("engine") ? ` · ${engine}` : ""}` : "", + engine, + mode: state.mode === "local" ? "local" : "remote", + activity: state.busy ? requestFeedback(state) : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : "", + inbox: queuedInbox ? `${queuedInbox} inbox queued` : "", + }; + const muted = (text) => paint(useColor, "muted", text); + const spans = []; + let line = " "; + let x = 1; + for (const name of status) { + const text = plain[name] || ""; + if (!text) continue; + if (spans.length) { + line += muted(separator); + x += textWidth(separator); + } + spans.push({ name, x, width: textWidth(text) }); + // The model underlines under the mouse: it is the one fact that is a control. + line += name === "handle" && text.startsWith("@") ? coloredHandle(text, state.handleColors, useColor) : name === "model" && state.hover === "model" && useColor ? paint(useColor, "muted", `\x1b[4m${text}\x1b[24m`) : muted(text); + x += textWidth(text); + } + return { line, spans }; +} + export function transcriptLineCount(state, columns = 80, rows = 24, useColor = true) { const width = Math.max(32, columns), height = Math.max(10, rows); const qr = useColor && state.qr && width >= state.qr.width + 24 && height - 5 >= state.qr.height ? state.qr : null; @@ -740,8 +765,15 @@ export function transcriptLineCount(state, columns = 80, rows = 24, useColor = t // Terminal mouse coordinates are one-based, like the displayed header row. export function headerAction(state, columns, rows, x, y) { if (columns < 32 || rows < 10) return ""; - // Pro draws no header and no model controls; nothing up there to click. - if (state.profile?.name === "pro") return ""; + // Pro draws no header and no model controls. The one thing to click is the + // model on the status line, which opens the settings drawer. + if (state.profile?.name === "pro") { + const shape = { bottom: ["gap", "bar", "gap", "status"], ...(state.layout || {}) }; + const row = shape.bottom.lastIndexOf("status"); + if (row < 0 || y !== rows - (shape.bottom.length - 1 - row)) return ""; + const hit = proStatus(state, Math.max(32, columns), false, shape).spans.find((span) => span.name === "model" && x >= span.x + 1 && x <= span.x + span.width); + return hit ? "model" : ""; + } if(y===rows-2){const hit=modelControls(state,columns).find(c=>x>=c.x&&x { assert.match(rows[8], /^ > /, "the prompt glyph is the layout's"); assert.equal(rows[9].trim(), "gpt-6-astra | codex", "only the facts asked for, with the separator asked for"); }); + +test("in pro the model on the status line is the one thing to click, and it opens settings", async () => { + const { headerAction, proStatus } = await import("../src/render.mjs"); + const state = { + workspace: "/client", mode: "remote", status: "ready", busy: false, input: "", + account: "@tester", model: "claude-sonnet-5", profile: { name: "pro" }, entries: [], + }; + const { spans } = proStatus(state, 80, false); + const model = spans.find((span) => span.name === "model"); + assert.ok(model, "the model is on the line"); + assert.equal(spans[0].name, "handle"); + assert.equal(spans[0].x, 1, "the line starts one cell in"); + // Terminal mouse coordinates are one-based; the status line is the last row. + assert.equal(headerAction(state, 80, 24, model.x + 1, 24), "model"); + assert.equal(headerAction(state, 80, 24, model.x + model.width, 24), "model"); + assert.equal(headerAction(state, 80, 24, model.x + model.width + 2, 24), "", "past the model is nothing"); + assert.equal(headerAction(state, 80, 24, model.x + 1, 23), "", "the row above is the air below the bar"); + assert.equal(headerAction(state, 80, 24, 3, 22), "", "no header band to click in pro"); + const moved = { ...state, layout: { bottom: ["status", "gap", "bar"] } }; + const movedModel = proStatus(moved, 80, false, moved.layout).spans.find((span) => span.name === "model"); + assert.equal(headerAction(moved, 80, 24, movedModel.x + 1, 22), "model", "the click follows the layout"); +});