Skip to content

Commit a01f4df

Browse files
committed
Bind ask selector rows by request id
1 parent a9fe428 commit a01f4df

21 files changed

Lines changed: 400 additions & 60 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2222
not a second copy of `env.authorize`: it consumes the prior verdict when the
2323
same call (id, name, and arguments) is cached, and decides on a cache miss.
2424

25+
### Fixed
26+
27+
- Sequential TUI ask and permission selectors paint the live question's option
28+
labels. Rows bind by ask and request id rather than render-order index, so a
29+
later question cannot keep the previous question's choices.
30+
2531
## [0.3.18] - 2026-09-08
2632

2733
### Added

‎docs/TUI.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ viewport kit: shared windowing, keep-active-visible, and page/jump behavior.
342342
There is exactly one scroll lease at a time; keyboard paging and the mouse
343343
wheel both follow whichever surface currently holds it, so a modal open on
344344
top of the transcript never lets the wheel move the transcript underneath it.
345+
Ask and permission rows bind by ask/request id, never render-order index;
346+
painted labels are the live payload, and stale rows whose ids are not in the
347+
new payload are dropped.
345348

346349
"Current" is never inferred. For the model picker, the row marked
347350
`(current)` is read live from the session's actual active provider/model on

‎src/permission/gate.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,39 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
236236
expect(prompts).toBe(1);
237237
});
238238
});
239+
240+
describe("ask decision mints request.id", () => {
241+
test("the copy handed to requestApproval has a non-empty id", async () => {
242+
let captured: PermissionRequest | undefined;
243+
const gate = createPermissionGate({
244+
approvals: [],
245+
interactive: true,
246+
skipPermissions: false,
247+
reactorGated: false,
248+
requestApproval: async (request) => {
249+
captured = request;
250+
return { allow: true };
251+
},
252+
});
253+
const verdict = await gate.evaluate(shellCall("npm test || true"));
254+
expect(verdict.allowed).toBe(true);
255+
expect(captured?.id).toEqual(expect.any(String));
256+
expect(captured?.id?.length).toBeGreaterThan(0);
257+
});
258+
259+
test("authorizeCall ask request carries a minted id", async () => {
260+
const gate = createPermissionGate({
261+
approvals: [],
262+
interactive: true,
263+
skipPermissions: false,
264+
reactorGated: false,
265+
requestApproval: async () => ({ allow: true }),
266+
});
267+
const result = await gate.authorizeCall(shellCall("npm test || true"));
268+
expect(result.effect).toBe("ask");
269+
if (result.effect !== "ask") throw new Error("expected ask");
270+
const id = result.request.id;
271+
expect(id).toEqual(expect.any(String));
272+
expect(id?.length).toBeGreaterThan(0);
273+
});
274+
});

‎src/permission/gate.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ToolCall } from "@intx/types/runtime";
22
import { isAbsolute, resolve } from "node:path";
3+
import { randomUUID } from "node:crypto";
34
import type {
45
Approval,
56
ApprovalOutcome,
@@ -47,6 +48,12 @@ import { NOOP_APPROVAL_LOG, type ApprovalLog, type ApprovalOutcomeKind } from ".
4748
// autoDeny in gate-wire.ts and the timeout branch in tui/request-approval.ts's
4849
// finish() usage); anything else that denies is a plain operator/unavailable
4950
// decision.
51+
function withRequestId(request: PermissionRequest): PermissionRequest {
52+
return request.id !== undefined && request.id.length > 0
53+
? request
54+
: { ...request, id: randomUUID() };
55+
}
56+
5057
function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeKind {
5158
if (outcome === undefined) return "deny";
5259
if (!outcome.allow) {
@@ -664,7 +671,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
664671
const requestForOperator = anySecret ? { ...request, scopes: [] } : request;
665672
return {
666673
kind: "ask",
667-
request: requestForOperator,
674+
request: withRequestId(requestForOperator),
668675
anySecret,
669676
segmentCount: segments.length,
670677
};
@@ -692,7 +699,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
692699
};
693700
}
694701

695-
return { kind: "ask", request, anySecret: false, segmentCount: 0 };
702+
return { kind: "ask", request: withRequestId(request), anySecret: false, segmentCount: 0 };
696703
}
697704
return { kind: "allow" };
698705
};

‎src/permission/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export interface PermissionRequest {
5656
// withheld for a reason beyond the ordinary "no persistent option exists
5757
// yet" case. Plain literal text, never model-authored.
5858
notice?: string;
59+
// Set by the gate on the copy handed to requestApproval, never on
60+
// buildRequests matching/display copies. Overlay rows bind by this id
61+
// rather than render-order index (see gate-wire.ts).
62+
id?: string;
5963
// Set by the gate right before handing this request to requestApproval, so
6064
// whichever surface actually renders it (see gate-wire.ts's overlay host)
6165
// can report the moment it reached the operator's screen — distinct from

‎src/tui/decision-truncation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const hintRequest: PermissionRequest = {
3333
hint: HINT,
3434
},
3535
],
36+
id: "req-1",
3637
};
3738

3839
function bodySelect(view: ReturnType<typeof createOverlayView>): SelectRenderable {

‎src/tui/gate-events.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { ApprovalOutcome, PermissionRequest } from "../permission/types.js"
22
import type { OperatorResult } from "../agent/tools.js";
33

44
export interface OperatorGateEvent {
5+
/** Minted by the session emitter, never by the TUI overlay. */
6+
id: string;
57
question: string;
68
options: string[];
79
resolve: (result: OperatorResult) => void;

0 commit comments

Comments
 (0)