Skip to content

Commit edfff2f

Browse files
Merge pull request #847 from corbitsdev/cl-7535-tui-askpermission-selector-renders-stale-or-mismatched
Bind ask selector rows by request id
2 parents a9fe428 + f809575 commit edfff2f

19 files changed

Lines changed: 760 additions & 108 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ 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. Overlay rows bind by an ask id minted at emit, not render-order
29+
index. A stale or empty accept fail-closes as unavailable rather than
30+
impersonating Reject; Escape still denies.
31+
2532
## [0.3.18] - 2026-09-08
2633

2734
### Added

docs/TUI.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,14 @@ 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 are namespaced by the ask id minted on the gate
346+
event at emit, before the overlay opens. Paint replaces the whole options
347+
array (labels and ids together);
348+
there is no drop-by-id merge. Enter binds by the painted id against the live
349+
bag — a painted value that is not in that bag, or Enter on an empty gate
350+
with no answer field, fail-closes the accept as unavailable rather than
351+
remapping by index or treating it as Reject. Escape still denies a
352+
permission and cancels an operator question through the dismiss path.
345353

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

src/permission/gate.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ import { NOOP_APPROVAL_LOG, type ApprovalLog, type ApprovalOutcomeKind } from ".
4242
// Closes out an operator prompt: ends the wait span and records the outcome.
4343
// buildRequests yields at most one request per tool call, and the two prompt
4444
// sites below are mutually exclusive, so this runs once per prompt shown.
45+
function finishApprovalWait(
46+
telemetry: Telemetry,
47+
waitSpanId: string,
48+
tool: string,
49+
outcome: ApprovalOutcome | undefined,
50+
): void {
51+
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
52+
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
53+
telemetry.capture("permission_prompt", {
54+
decision,
55+
permission_kind: classifyPermissionKind(tool),
56+
});
57+
}
58+
4559
// Classifies a settled ApprovalOutcome into the approval-log taxonomy.
4660
// gate-wire.ts's timeout/abort auto-denies carry a fixed message text (see
4761
// autoDeny in gate-wire.ts and the timeout branch in tui/request-approval.ts's
@@ -58,20 +72,6 @@ function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeK
5872
return outcome.persist !== undefined ? "allow-with-scope" : "allow-once";
5973
}
6074

61-
function finishApprovalWait(
62-
telemetry: Telemetry,
63-
waitSpanId: string,
64-
tool: string,
65-
outcome: ApprovalOutcome | undefined,
66-
): void {
67-
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
68-
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
69-
telemetry.capture("permission_prompt", {
70-
decision,
71-
permission_kind: classifyPermissionKind(tool),
72-
});
73-
}
74-
7575
export type GateVerdict = { allowed: true } | { allowed: false; reason: string };
7676

7777
// One shell segment's forced-ask guard: a secret-path reference or a

src/tui/decision-truncation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe("decision choice rendering", () => {
9090
const emitter = new EventEmitter();
9191
const dispose = wireGates(emitter, shell);
9292
emitter.emit("permission.gate", {
93+
id: "req-1",
9394
request: hintRequest,
9495
resolve: () => {},
9596
});

src/tui/gate-events.ts

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

4+
/** Fail-closed settle when no approval UI can bind the operator's accept. */
5+
export const APPROVAL_UNAVAILABLE_MESSAGE = "no approval UI available; request denied" as const;
6+
47
export interface OperatorGateEvent {
8+
/** Minted by the session emitter, never by the TUI overlay. */
9+
id: string;
510
question: string;
611
options: string[];
712
resolve: (result: OperatorResult) => void;
@@ -21,6 +26,8 @@ export interface OperatorGateEvent {
2126
}
2227

2328
export interface PermissionGateEvent {
29+
/** Minted by the session emitter at gate emit, never on PermissionRequest. */
30+
id: string;
2431
request: PermissionRequest;
2532
resolve: (outcome: ApprovalOutcome) => void;
2633
/**

0 commit comments

Comments
 (0)