Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
not a second copy of `env.authorize`: it consumes the prior verdict when the
same call (id, name, and arguments) is cached, and decides on a cache miss.

### Fixed

- Sequential TUI ask and permission selectors paint the live question's option
labels. Overlay rows bind by an ask id minted at emit, not render-order
index. A stale or empty accept fail-closes as unavailable rather than
impersonating Reject; Escape still denies.

## [0.3.18] - 2026-09-08

### Added
Expand Down
8 changes: 8 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,14 @@ viewport kit: shared windowing, keep-active-visible, and page/jump behavior.
There is exactly one scroll lease at a time; keyboard paging and the mouse
wheel both follow whichever surface currently holds it, so a modal open on
top of the transcript never lets the wheel move the transcript underneath it.
Ask and permission rows are namespaced by the ask id minted on the gate
event at emit, before the overlay opens. Paint replaces the whole options
array (labels and ids together);
there is no drop-by-id merge. Enter binds by the painted id against the live
bag — a painted value that is not in that bag, or Enter on an empty gate
with no answer field, fail-closes the accept as unavailable rather than
remapping by index or treating it as Reject. Escape still denies a
permission and cancels an operator question through the dismiss path.

"Current" is never inferred. For the model picker, the row marked
`(current)` is read live from the session's actual active provider/model on
Expand Down
28 changes: 14 additions & 14 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ import { NOOP_APPROVAL_LOG, type ApprovalLog, type ApprovalOutcomeKind } from ".
// Closes out an operator prompt: ends the wait span and records the outcome.
// buildRequests yields at most one request per tool call, and the two prompt
// sites below are mutually exclusive, so this runs once per prompt shown.
function finishApprovalWait(
telemetry: Telemetry,
waitSpanId: string,
tool: string,
outcome: ApprovalOutcome | undefined,
): void {
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
telemetry.capture("permission_prompt", {
decision,
permission_kind: classifyPermissionKind(tool),
});
}

// Classifies a settled ApprovalOutcome into the approval-log taxonomy.
// gate-wire.ts's timeout/abort auto-denies carry a fixed message text (see
// autoDeny in gate-wire.ts and the timeout branch in tui/request-approval.ts's
Expand All @@ -58,20 +72,6 @@ function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeK
return outcome.persist !== undefined ? "allow-with-scope" : "allow-once";
}

function finishApprovalWait(
telemetry: Telemetry,
waitSpanId: string,
tool: string,
outcome: ApprovalOutcome | undefined,
): void {
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
telemetry.capture("permission_prompt", {
decision,
permission_kind: classifyPermissionKind(tool),
});
}

export type GateVerdict = { allowed: true } | { allowed: false; reason: string };

// One shell segment's forced-ask guard: a secret-path reference or a
Expand Down
1 change: 1 addition & 0 deletions src/tui/decision-truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe("decision choice rendering", () => {
const emitter = new EventEmitter();
const dispose = wireGates(emitter, shell);
emitter.emit("permission.gate", {
id: "req-1",
request: hintRequest,
resolve: () => {},
});
Expand Down
7 changes: 7 additions & 0 deletions src/tui/gate-events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { ApprovalOutcome, PermissionRequest } from "../permission/types.js";
import type { OperatorResult } from "../agent/tools.js";

/** Fail-closed settle when no approval UI can bind the operator's accept. */
export const APPROVAL_UNAVAILABLE_MESSAGE = "no approval UI available; request denied" as const;

export interface OperatorGateEvent {
/** Minted by the session emitter, never by the TUI overlay. */
id: string;
question: string;
options: string[];
resolve: (result: OperatorResult) => void;
Expand All @@ -21,6 +26,8 @@ export interface OperatorGateEvent {
}

export interface PermissionGateEvent {
/** Minted by the session emitter at gate emit, never on PermissionRequest. */
id: string;
request: PermissionRequest;
resolve: (outcome: ApprovalOutcome) => void;
/**
Expand Down
Loading
Loading