Skip to content

Commit 38cc8ba

Browse files
committed
Bind ask selector rows by request id
1 parent 6ea5969 commit 38cc8ba

21 files changed

Lines changed: 402 additions & 60 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Fixed
17+
18+
- Sequential TUI ask and permission selectors paint the live question's option
19+
labels. Rows bind by ask and request id rather than render-order index, so a
20+
later question cannot keep the previous question's choices.
21+
1422
## [0.3.18] - 2026-09-08
1523

1624
### 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) {
@@ -646,7 +653,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
646653
const requestForOperator = anySecret ? { ...request, scopes: [] } : request;
647654
return {
648655
kind: "ask",
649-
request: requestForOperator,
656+
request: withRequestId(requestForOperator),
650657
anySecret,
651658
segmentCount: segments.length,
652659
};
@@ -674,7 +681,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
674681
};
675682
}
676683

677-
return { kind: "ask", request, anySecret: false, segmentCount: 0 };
684+
return { kind: "ask", request: withRequestId(request), anySecret: false, segmentCount: 0 };
678685
}
679686
return { kind: "allow" };
680687
};

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)