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
177 changes: 177 additions & 0 deletions src/tui-opentui/gate-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,183 @@ describe("wireGates", () => {
})
})

describe("each gate decision appends exactly one transcript row", () => {
test("permission accept", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
emitter.emit("permission.gate", { request: baseRequest(), resolve: () => {} })

const before = shell.streamLog.length
acceptOverlaySelection(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("permission Esc/deny", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
emitter.emit("permission.gate", { request: baseRequest(), resolve: () => {} })

const before = shell.streamLog.length
closeInsetOverlay(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("operator accept", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
emitter.emit("operator.gate", {
question: "Proceed?",
options: ["Cancel", "Continue"],
resolve: () => {},
})

const before = shell.streamLog.length
acceptOverlaySelection(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("operator Esc/cancel", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
emitter.emit("operator.gate", {
question: "Proceed?",
options: ["Cancel", "Continue"],
resolve: () => {},
})

const before = shell.streamLog.length
closeInsetOverlay(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("operator typed answer", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
emitter.emit("operator.gate", {
question: "Proceed?",
options: ["Cancel", "Continue"],
resolve: () => {},
})

setOverlayAnswerActive(shell, true)
const before = shell.streamLog.length
for (const ch of "yes") {
handleOverlayAnswerKey(shell, {
name: ch,
sequence: ch,
ctrl: false,
meta: false,
option: false,
} as unknown as KeyEvent)
}
handleOverlayAnswerKey(shell, {
name: "return",
sequence: "",
ctrl: false,
meta: false,
option: false,
} as unknown as KeyEvent)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("permission auto-deny on timeout", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
try {
wireGates(emitter, shell)
const before = shell.streamLog.length
emitter.emit("permission.gate", {
request: baseRequest(),
resolve: () => {},
timeoutMs: 5,
})
await new Promise((r) => setTimeout(r, 20))
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})

test("permission auto-deny on abort", async () => {
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
run: "idle",
})
const emitter = new EventEmitter()
const controller = new AbortController()
try {
wireGates(emitter, shell)
const before = shell.streamLog.length
emitter.emit("permission.gate", {
request: baseRequest(),
resolve: () => {},
signal: controller.signal,
})
controller.abort()
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
})
})
})

describe("permission.gate auto-deny", () => {
test("timeoutMs elapsing auto-denies with the timeout message and closes the overlay", async () => {
await withTestRenderer(async (h) => {
Expand Down
39 changes: 33 additions & 6 deletions src/tui-opentui/gate-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@ function recordDecision(
appendStreamRow(shell, { role: "system", text, meta: "permission" })
}

/**
* Write the operator's question and answer to the transcript, once decided.
* Mirrors recordDecision: the overlay already shows this text while it is
* open, so an immediate echo would print every operator question twice.
*/
function recordOperatorDecision(
shell: AppShell,
question: string,
label: string,
): void {
const body = middleEllipsis(question, 500)
const text = `${body}\n→ ${label}`
appendStreamRow(shell, { role: "system", text, meta: "operator" })
}

/**
* Subscribe the permission/operator gate events to the shell's overlays.
* Returns a dispose function that removes exactly the listeners this call added.
Expand Down Expand Up @@ -289,6 +304,10 @@ export function wireGates(
items: choices.items,
itemIds: choices.itemIds,
body: collapsedBody,
// recordDecision below is the authoritative transcript row for every
// terminal path — the overlay's own accept/answer echo would
// duplicate it.
echoChoice: false,
...(collapsedAnything ? { onToggleExpand } : {}),
onAccept: (sel: OverlaySelection) => {
if (settled) return
Expand All @@ -307,12 +326,9 @@ export function wireGates(
if (settled) return
settled = true
clearTimers()
ev.resolve(
approvalOutcomeFromSelection(choices, {
index: 0,
id: PERMISSION_DENY_ID,
}),
)
const gateSelection = { index: 0, id: PERMISSION_DENY_ID }
recordDecision(shell, ev.request, choices, gateSelection)
ev.resolve(approvalOutcomeFromSelection(choices, gateSelection))
},
})
}
Expand All @@ -332,6 +348,10 @@ export function wireGates(
if (settled) return
settled = true
clearTimers()
recordDecision(shell, ev.request, choices, {
index: 0,
id: PERMISSION_DENY_ID,
})
if (isOpen) {
closeInsetOverlay(shell)
} else {
Expand Down Expand Up @@ -368,9 +388,14 @@ export function wireGates(
body: ev.question,
choices: choices.items,
itemIds: choices.itemIds,
// recordOperatorDecision below is the authoritative transcript row for
// every terminal path — the overlay's own accept/answer echo would
// duplicate it.
echoChoice: false,
onAccept: (sel: OverlaySelection) => {
if (settled) return
settled = true
recordOperatorDecision(shell, ev.question, sel.label)
ev.resolve(
operatorResultFromSelection(ev.options, {
index: sel.index,
Expand All @@ -383,13 +408,15 @@ export function wireGates(
onTextAnswer: (text: string) => {
if (settled) return
settled = true
recordOperatorDecision(shell, ev.question, text)
ev.resolve(operatorCustomResult(text))
},
// Esc must settle the awaited promise (as a cancel), not abandon it —
// an unresolved gate hangs the run until the process is killed.
onCancel: () => {
if (settled) return
settled = true
recordOperatorDecision(shell, ev.question, "Cancelled")
ev.resolve(operatorCancelResult())
},
}))
Expand Down
65 changes: 65 additions & 0 deletions src/tui-opentui/overlays.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,68 @@ describe("accept echo reads the chosen value structurally", () => {
)
})
})

describe("echoChoice defaults to on for callers with no recorder of their own", () => {
test("openPermissionsOverlay with no echoChoice opt still echoes on accept", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
})
try {
openPermissionsOverlay(shell, { items: makePermissionItems(3) })
const before = shell.streamLog.length
acceptOverlaySelection(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("openPermissionsOverlay with echoChoice: false suppresses it", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
})
try {
openPermissionsOverlay(shell, {
items: makePermissionItems(3),
echoChoice: false,
})
const before = shell.streamLog.length
acceptOverlaySelection(shell)
expect(shell.streamLog.length - before).toBe(0)
} finally {
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("openOperatorOverlay with no echoChoice opt still echoes on accept", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
})
try {
openOperatorOverlay(shell, { choices: ["A", "B"] })
const before = shell.streamLog.length
acceptOverlaySelection(shell)
expect(shell.streamLog.length - before).toBe(1)
} finally {
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})
})
18 changes: 18 additions & 0 deletions src/tui-opentui/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export type OpenPermissionsOpts = {
readonly onToggleExpand?: () => void
/** Per-open Esc/dismiss; host binds resolve(ApprovalOutcome) so Esc denies instead of hanging. */
readonly onCancel?: () => void
/**
* Suppress the generic accept/answer echo for this open. Callers that
* record their own authoritative decision row (e.g. gate-wire's
* recordDecision) pass `false` so the generic echo does not duplicate it;
* callers with no such recorder (e.g. the standalone demo) get the default
* echo so their choice still leaves a trace.
*/
readonly echoChoice?: boolean
}

export function openPermissionsOverlay(
Expand All @@ -111,6 +119,7 @@ export function openPermissionsOverlay(
: {}),
...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}),
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}),
})
}

Expand All @@ -125,6 +134,14 @@ export type OpenOperatorOpts = {
readonly onTextAnswer?: (text: string) => void
/** Per-open Esc/dismiss; host binds resolve(cancel) so Esc cancels instead of hanging. */
readonly onCancel?: () => void
/**
* Suppress the generic accept/answer echo for this open. Callers that
* record their own authoritative decision row (e.g. gate-wire's
* recordOperatorDecision) pass `false` so the generic echo does not
* duplicate it; callers with no such recorder (e.g. the standalone demo)
* get the default echo so their choice still leaves a trace.
*/
readonly echoChoice?: boolean
}

/**
Expand Down Expand Up @@ -156,6 +173,7 @@ export function openOperatorOverlay(
? { onTextAnswer: opts.onTextAnswer }
: {}),
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}),
})
}

Expand Down
Loading
Loading