diff --git a/src/backend/client.ts b/src/backend/client.ts index 73c1621..4af3d73 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -217,6 +217,16 @@ export class ZcodeBackend { return this.serverRequests.splice(0, this.serverRequests.length); } + /** + * Re-queue server→client requests that belong to a different session (prepended + * to preserve arrival order). Used by `handleServerRequests` to put back + * requests it popped but doesn't own. + */ + requeueServerRequests(reqs: ServerRequest[]): void { + if (reqs.length === 0) return; + this.serverRequests.unshift(...reqs); + } + /** Reply to a zcode server→client request with a result (id + result). */ sendReply(id: number, result: unknown): void { const stdin = this.proc.stdin; diff --git a/src/handlers/server-requests.ts b/src/handlers/server-requests.ts index 0029db8..8eca20b 100644 --- a/src/handlers/server-requests.ts +++ b/src/handlers/server-requests.ts @@ -61,8 +61,17 @@ export function getPendingInteractions(server: ZcodeAcpServer): Map { const pending = getPendingInteractions(server); let handled = false; + const mySid = turn?.zcodeSid; for (;;) { - const req = backend.pollServerRequests().shift() ?? null; - if (!req) return handled; + const all = backend.pollServerRequests(); + if (all.length === 0) return handled; + // Without a session filter (no turn), process everything — legacy path. + if (mySid === undefined) { + for (const req of all) { + handled = true; + await handleOne(server, backend, cx, acpSid, req, pending, turn); + } + continue; + } + // Pick the first request belonging to this session; put the rest back. + // Requests without a sessionId field are unrouteable — claim them here + // so they don't sit in the queue forever. + let mine: ServerRequest | undefined; + const others: ServerRequest[] = []; + for (const r of all) { + const sid = (r.params as { sessionId?: string }).sessionId; + if (!mine && (sid === undefined || sid === mySid)) { + mine = r; + } else { + others.push(r); + } + } + // Re-queue the ones that don't belong to this session (prepend to preserve order). + if (others.length > 0) backend.requeueServerRequests(others); + if (!mine) return handled; handled = true; - await handleOne(server, backend, cx, acpSid, req, pending, turn); + await handleOne(server, backend, cx, acpSid, mine, pending, turn); } } diff --git a/src/quota/format.ts b/src/quota/format.ts index 4dfa6d3..bdaaf26 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -1,17 +1,27 @@ /** * Quota result formatting — renders a {@link QuotaResult} as a multi-line - * plain-text card for the `agent_message_chunk` feedback. + * plain-text card inside a ```text fenced block. + * + * The whole card is wrapped in a fenced code block so the editor renders it in + * a monospace, bordered, copy-able frame. Monospace also normalises the block + * element widths (▓/░ are NOT marked East-Asian-Wide, but some proportional + * chat fonts stretch them; a code frame guarantees 1-cell-per-bar). * * The progress bar uses two block characters at {@link BAR_WIDTH}-cell width - * for a clean, precise fill (each cell = 10%): - * █ (U+2588, full) — used portion - * ░ (U+2591, light) — remaining portion + * for a clean, precise fill (each cell = 5%): + * █ (U+2588, full block) — used portion + * ░ (U+2591, light shade) — remaining portion + * Inside a monospace code block the full-block reads as a solid, high-impact + * fill for used quota, with the light shade marking the remainder. */ import type { QuotaItem, QuotaResult } from "./types.js"; -/** Progress-bar cell count. Compact to fit chat-frame width without wrapping. */ -const BAR_WIDTH = 10; +/** + * Progress-bar cell count. 20 cells = 5% granularity, fine enough to read + * real usage at a glance while still fitting a code-block frame on one line. + */ +const BAR_WIDTH = 20; /** Filled / empty cell characters. */ const CHAR_FULL = "█"; @@ -108,11 +118,17 @@ const STATUS_MESSAGES: Record, string> = }; /** - * Render a {@link QuotaResult} as a multi-line plain-text card. + * Render a {@link QuotaResult} as a multi-line plain-text card wrapped in a + * ```text fenced block. + * + * The fence makes the editor render the card in a monospace, bordered, copy- + * able frame — without it the proportional chat font mangles block-element + * widths (▓/░ render at different glyph advances and can look stretched), and + * the divider/bars lose their alignment. * * Success → header line + divider + one progress-bar line per item (plus * indented per-model detail where present). Non-success kinds → a single - * explanatory line. + * explanatory line, unfenced (they are short prose, not a card). */ export function formatQuota(result: QuotaResult): string { if (result.kind !== "success") { @@ -120,7 +136,9 @@ export function formatQuota(result: QuotaResult): string { } const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`; - const divider = "─".repeat(50); + // Divider spans the longest line so the frame looks balanced; 34 ≈ label(5) + // + space(1) + bar(20) + spaces(2) + "NN%"(3) + trailing-room(3). + const divider = "─".repeat(34); const body = result.items.flatMap(formatItem); - return [header, divider, ...body].join("\n"); + return ["```text", header, divider, ...body, "```"].join("\n"); } diff --git a/tests/backend.test.ts b/tests/backend.test.ts index 0402ec0..c87477f 100644 --- a/tests/backend.test.ts +++ b/tests/backend.test.ts @@ -105,4 +105,26 @@ describe("ZcodeBackend reader routing (unit)", () => { expect(resp2.error?.message).toMatch(/reader exited/); b.close(); }); + + it("requeueServerRequests prepends so re-drained requests keep their order", () => { + const b = makeRoutingSubject(); + b.route({ + id: 5001, + method: "interaction/requestPermission", + params: { requestId: "r1", toolCallId: "t1", sessionId: "sess_a" }, + }); + b.route({ + id: 5002, + method: "interaction/requestPermission", + params: { requestId: "r2", toolCallId: "t2", sessionId: "sess_b" }, + }); + // Drain both, then requeue the second (simulating a session filter put-back). + const all = b.pollServerRequests(); + expect(all).toHaveLength(2); + b.requeueServerRequests([all[1]!]); + const remaining = b.pollServerRequests(); + expect(remaining).toHaveLength(1); + expect((remaining[0]!.params as { sessionId: string }).sessionId).toBe("sess_b"); + b.close(); + }); }); diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 67e99f4..a18cad9 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -165,29 +165,29 @@ describe("parseQuotaEnvelope", () => { describe("renderBar", () => { it("0% → all empty cells", () => { - expect(renderBar(0)).toBe("░".repeat(10)); + expect(renderBar(0)).toBe("░".repeat(20)); }); it("100% → all full cells", () => { - expect(renderBar(100)).toBe("█".repeat(10)); + expect(renderBar(100)).toBe("█".repeat(20)); }); - it("50% → 5 full + 5 empty", () => { - expect(renderBar(50)).toBe("█".repeat(5) + "░".repeat(5)); + it("50% → 10 full + 10 empty", () => { + expect(renderBar(50)).toBe("█".repeat(10) + "░".repeat(10)); }); - it("rounds to the nearest cell (each cell = 10%)", () => { - // 24% of 10 = 2.4 → rounds to 2 full. - expect(renderBar(24)).toBe("██" + "░".repeat(8)); - // 5% of 10 = 0.5 → rounds to 1 full. - expect(renderBar(5)).toBe("█" + "░".repeat(9)); - // 21% of 10 = 2.1 → rounds to 2 full. - expect(renderBar(21)).toBe("██" + "░".repeat(8)); + it("rounds to the nearest cell (each cell = 5%)", () => { + // 24% of 20 = 4.8 → rounds to 5 full. + expect(renderBar(24)).toBe("█".repeat(5) + "░".repeat(15)); + // 5% of 20 = 1 → 1 full. + expect(renderBar(5)).toBe("█" + "░".repeat(19)); + // 21% of 20 = 4.2 → rounds to 4 full. + expect(renderBar(21)).toBe("█".repeat(4) + "░".repeat(16)); }); it("clamps inputs outside [0, 100]", () => { - expect(renderBar(150)).toBe("█".repeat(10)); - expect(renderBar(-10)).toBe("░".repeat(10)); + expect(renderBar(150)).toBe("█".repeat(20)); + expect(renderBar(-10)).toBe("░".repeat(20)); }); }); @@ -221,21 +221,25 @@ describe("formatQuota", () => { }; const out = formatQuota(result); const lines = out.split("\n"); - expect(lines[0]).toBe("GLM Coding Plan · Pro"); - expect(lines[1]).toMatch(/^─+$/); + // The whole card is wrapped in a ```text fenced block so the editor + // renders it in a bordered, monospace, copy-able frame. + expect(lines[0]).toBe("```text"); + expect(lines[1]).toBe("GLM Coding Plan · Pro"); + expect(lines[2]).toMatch(/^─+$/); // 5h line: percent + reset time, no "resets" word, no absolute counts. - expect(lines[2]).toContain("5h"); - expect(lines[2]).toContain("5%"); - expect(lines[2]).not.toContain("resets"); - expect(lines[2]).not.toMatch(/\(\d+\/\d+\)/); - // MCP line: percent + absolute counts + reset time. - expect(lines[3]).toContain("MCP"); - expect(lines[3]).toContain("24%"); - expect(lines[3]).toContain("(237/1000)"); + expect(lines[3]).toContain("5h"); + expect(lines[3]).toContain("5%"); expect(lines[3]).not.toContain("resets"); + expect(lines[3]).not.toMatch(/\(\d+\/\d+\)/); + // MCP line: percent + absolute counts + reset time. + expect(lines[4]).toContain("MCP"); + expect(lines[4]).toContain("24%"); + expect(lines[4]).toContain("(237/1000)"); + expect(lines[4]).not.toContain("resets"); // Detail branches (now padded model codes). - expect(lines[4]).toMatch(/├ search-prime\s+\d+/); - expect(lines[5]).toMatch(/└ web-reader\s+\d+/); + expect(lines[5]).toMatch(/├ search-prime\s+\d+/); + expect(lines[6]).toMatch(/└ web-reader\s+\d+/); + expect(lines[lines.length - 1]).toBe("```"); }); it("omits absolute counts when the limit carries no counters (5h)", () => {