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
10 changes: 7 additions & 3 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ const PathArgSchema = type({ path: "string" });
export const askOperatorDefinition: ToolDefinition = {
name: "ask_operator",
description:
"Pause execution and ask the operator a clarifying question. Execution resumes when the operator selects an option. " +
"Pause execution and ask the operator a short clarifying question with short option labels. " +
"Put any long rationale, trade-offs, or context in a normal transcript reply first, then call this " +
"with only a brief question and brief option labels — the overlay is not a place for essays. " +
"Execution resumes when the operator selects an option. " +
"If the question is really asking permission to run one specific shell command, pass that exact command as `command` " +
"instead of just describing it in the option text — approval here then covers the matching run_shell call too, so the " +
"operator is not asked to approve the same action twice.",
Expand All @@ -123,11 +126,12 @@ export const askOperatorDefinition: ToolDefinition = {
properties: {
question: {
type: "string",
description: "The question to ask the operator",
description:
"Short question shown in the overlay (one or two lines). Put long rationale in a transcript reply first.",
},
options: {
type: "array",
description: "List of options the operator can choose from",
description: "Short option labels the operator can choose from (keep each label brief)",
items: { type: "string" },
minItems: 1,
},
Expand Down
4 changes: 2 additions & 2 deletions src/agent/directors/skywalker/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Quick routing:
- After multi-file builder landings → default a critic (or greybeard when architecture is in play) on the diff/criteria in a fresh context

Prefer typed spawn: intent, success_criteria, do_not, report_focus, agent when specialist.
Parallelize independent lanes with spawn_agent, then wait_agents. manage_tasks for your checklist. ask_operator when blocked or ambiguous.
Parallelize independent lanes with spawn_agent, then wait_agents. manage_tasks for your checklist. ask_operator when blocked or ambiguous — put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only.

# Fetch URLs (primary-mounted)

Expand Down Expand Up @@ -117,7 +117,7 @@ Docs/design (PRODUCT.md, ARCHITECTURE.md, docs/design/*, brand) still spawn shak

## If ORCHESTRATION → coordinate

Track with manage_tasks. Parallelize independent lanes via spawn_agent + wait_agents. After each spawn wave, update the operator before blocking. Escalate blockers with ask_operator. This is your core role.
Track with manage_tasks. Parallelize independent lanes via spawn_agent + wait_agents. After each spawn wave, update the operator before blocking. Escalate blockers with ask_operator (chat rationale first, then short ask_operator). This is your core role.

## If COMMUNICATION → answer directly

Expand Down
1 change: 1 addition & 0 deletions src/agent/prompt-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const CHAT_PROMPT_QUALITY_MARKERS = [
"Scope and conventions:",
"DIY tiny/single-file/one-route",
"ask_operator only when permission blocks you",
"short question and short option labels only",
"Touch only code required for the task",
"load the style and philosophy skills",
"grep or search_files",
Expand Down
4 changes: 3 additions & 1 deletion src/agent/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export function buildGuidelines(
]
: [
"- Clear, bounded coding requests: proceed autonomously; use ask_operator only when permission blocks you or the request is genuinely ambiguous (missing repro, conflicting instructions, destructive choice).",
"- Before ask_operator: put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only.",
"- Questions, reviews, and product/visual feedback: answer or diagnose first; do not edit until the user wants a change.",
"- Preserve unrelated user edits; never revert changes you did not make unless asked.",
"- Unexpected changes in files you did not touch: stop and ask_operator.",
Expand Down Expand Up @@ -219,7 +220,8 @@ const TOOL_SUMMARIES: Record<string, string> = {
"find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace",
manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel",
submit_output: "signal the task is complete — the only way to finish",
ask_operator: "pause and ask the user when blocked or genuinely ambiguous",
ask_operator:
"pause and ask the user when blocked or genuinely ambiguous; put long rationale in a transcript reply first, then call with a short question and short option labels only",
present:
"dynamically render aligned/structured output using the layout primitives (stack/row/grid/text etc)",
tool_search: "load more tools by capability when you need them",
Expand Down
47 changes: 38 additions & 9 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
surfaceSystemNotice,
toggleTasksPanel,
} from "./shell";
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays";
import { makePermissionItems, openPermissionsOverlay } from "./overlays";
import {
LANDING_HINTS,
LANDING_SUGGESTIONS,
Expand Down Expand Up @@ -429,7 +429,26 @@ describe("landing screen", () => {
// together before the overlay opens.
expect(was).toEqual([...was].sort((a, b) => a - b));

openOperatorOverlay(shell);
// Heavy inset permission overlay: many choices plus a multi-line body
// so the float must take real headroom from the landing split. A
// three-item empty body leaves message delta 0 and would pass even if
// the split never slid. Operator asks use full_shell (CL-7067) and
// hide the landing instead — that path is covered in overlays.test.ts.
const heavyBody = [
"run_shell",
"Run shell command",
"Proposed: git reset --hard origin/main && rm -rf node_modules",
"Files at risk: 128 modified, 12 untracked.",
"Continue only if you accept discarding local work.",
"Also note: this path was requested by the explore agent.",
"Scopes include session, project, and once-only grants.",
"Review carefully before approving this request.",
].join("\n");
openPermissionsOverlay(shell, {
items: makePermissionItems(16),
body: heavyBody,
});
expect(shell.layout.overlayMode).toBe("inset");
await settle(h);
const after = rows(h);
// Every landing anchor is still on screen and in the same relative
Expand All @@ -442,6 +461,9 @@ describe("landing screen", () => {
expect(nowAt.every((index) => index > 0)).toBe(true);
expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b));
expect(new Set(nowAt).size).toBe(nowAt.length);
// Real geometry pressure: the prompt field moves so the inset can
// claim rows the even split would not have given it.
expect(nowAt[0]).not.toBe(was[0]);
expect(h.captureCharFrame()).toContain("Esc cancel");
} finally {
shell.dispose();
Expand All @@ -451,12 +473,14 @@ describe("landing screen", () => {
);
});

// A question with more choices than the even top/bottom split would leave
// room for used to get its list starved down to whatever that split
// happened to allow — as little as one or two choices — because the float
// only asked the split for one choice row of headroom. It now asks for the
// overlay's real, already fraction-capped content height, so a terminal
// An inset permission list with more choices than the even top/bottom split
// would leave room for used to get its list starved down to whatever that
// split happened to allow — as little as one or two choices — because the
// float only asked the split for one choice row of headroom. It now asks for
// the overlay's real, already fraction-capped content height, so a terminal
// tall enough for that content shows every choice without scrolling.
// Operator full_shell hides the landing (CL-7067); this coverage stays on
// the inset permission path that still floats over landing.
test("a landing overlay with many choices shows them all when there is room", async () => {
await withTestRenderer(
async (h) => {
Expand All @@ -466,10 +490,15 @@ describe("landing screen", () => {
run: "idle",
});
try {
openOperatorOverlay(shell);
const items = makePermissionItems(8);
openPermissionsOverlay(shell, {
items,
body: "run_shell\nRun shell command\nbun test src/tui",
});
expect(shell.layout.overlayMode).toBe("inset");
await settle(h);
const frame = h.captureCharFrame();
for (const choice of makeOperatorQuestion().choices) {
for (const choice of items) {
expect(frame).toContain(choice);
}
} finally {
Expand Down
63 changes: 63 additions & 0 deletions src/tui/overlays.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ describe("operator question overlay", () => {
try {
openOperatorOverlay(shell);
expect(shell.overlayKind).toBe("operator");
expect(shell.layout.overlayMode).toBe("full_shell");
expect(shell.overlayBodyLines.length).toBeGreaterThan(0);
expect(shell.overlayItems.length).toBeGreaterThan(3);
expect(focusOwner(shell.focus)).toBe("overlay");
Expand Down Expand Up @@ -222,6 +223,68 @@ describe("operator question overlay", () => {
{ width: 80, height: 24 },
);
});

test("full_shell decisionContext budget shows more body rows than inset", async () => {
// Same long body under both modes: inset stays capped at DECISION_CONTEXT_ROWS
// (8), while full_shell raises the cap with terminal height so a long ask
// stays readable (CL-7067). Mode-only asserts are not enough — pin the
// budget branch that actually shapes overlayBodyLines.
const longBody = [
"Should we proceed with the destructive reset of the working tree?",
...Array.from({ length: 24 }, (_, i) => `Context line ${i + 1}.`),
].join("\n");
const choices = [
"Cancel — keep working tree",
"Allow this once",
"Allow for this session",
] as const;
const size = { width: 80, height: 40 } as const;

async function bodyLineCount(mode: "inset" | "full_shell"): Promise<{
readonly lines: number;
readonly body: readonly string[];
readonly frame: string;
}> {
return withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: size.width, rows: size.height },
wireKeys: false,
run: "idle",
});
try {
openListOverlay(shell, {
kind: "operator",
title: "",
body: longBody,
items: [...choices],
overlayMode: mode,
frameId: `overlay-operator-${mode}`,
});
expect(shell.layout.overlayMode).toBe(mode);
await h.renderOnce();
await h.renderOnce();
return {
lines: shell.overlayBodyLines.length,
body: shell.overlayBodyLines,
frame: h.captureCharFrame(),
};
} finally {
shell.dispose();
}
}, size);
}

const inset = await bodyLineCount("inset");
const full = await bodyLineCount("full_shell");
expect(full.lines).toBeGreaterThan(inset.lines);
// Short, non-wrapping context lines map 1:1 to the budget. Inset caps at
// eight context rows (then dither + tail); full_shell's raised cap keeps a
// mid-body line the inset path drops.
expect(full.body.some((line) => line.includes("Context line 12."))).toBe(true);
expect(inset.body.some((line) => line.includes("Context line 12."))).toBe(false);
expect(full.frame).toContain("Context line 12.");
expect(inset.frame).not.toContain("Context line 12.");
});
});

describe("model / provider picker", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/tui/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): v
items: choices,
activeIndex: opts?.activeIndex ?? 0,
frameId: "overlay-operator",
// Full shell so long questions and option lists stay readable (CL-7067).
// Permission gates keep the default inset path.
overlayMode: "full_shell",
...(opts?.itemIds !== undefined ? { itemIds: opts.itemIds } : {}),
...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}),
...(opts?.onTextAnswer !== undefined ? { onTextAnswer: opts.onTextAnswer } : {}),
Expand Down
35 changes: 32 additions & 3 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,8 +1282,12 @@ function relayoutOverlayHost(shell: AppShell, itemCount: number): void {
const perItem = overlayRowsPerItem(shell.overlayKind);
const hostRows = overlayHostRows(shell, shell.overlayBodyLines.length, itemCount * perItem);
const minHostRows = overlayMinHostRows(shell, shell.overlayBodyLines.length, itemCount > 0);
// Preserve the mode the open chose (operator uses full_shell; others inset).
// Hardcoding inset here would collapse a full_shell ask on every list refresh.
const bag = internals.get(shell);
const mode: OverlayMode = bag?.overlayMode === "full_shell" ? "full_shell" : "inset";
relayout(shell, {
overlayMode: "inset",
overlayMode: mode,
overlayBodyRows: hostRows,
overlayMinBodyRows: minHostRows,
});
Expand Down Expand Up @@ -1937,6 +1941,8 @@ interface PriorOverlaySnapshot {
readonly onCancel: (() => void) | null;
readonly addProviderHint: boolean;
readonly setDefaultHint: boolean;
/** Geometry mode the primary used before the palette stacked over it. */
readonly overlayMode: OverlayMode;
}

interface ShellInternals {
Expand Down Expand Up @@ -3429,6 +3435,16 @@ function decisionContextBudget(
): number {
const fixedChrome =
OVERLAY_HOST_BORDER_ROWS + overlayTitleRows(kind) + DECISION_HEADER_AND_TRAILER_ROWS;
// full_shell owns residual after chrome — drop the inset fraction cap so a
// longer question can use the extra rows instead of staying clipped at eight.
const bag = internals.get(shell);
if (bag?.overlayMode === "full_shell") {
const maxOverlayRows = Math.max(0, terminalHeight - PROMPT_BASE_ROWS);
const baseline =
maxOverlayRows - DECISION_CHOICE_ROWS - fixedChrome - DECISION_CONTEXT_BLANK_ROWS;
const fullCap = Math.max(DECISION_CONTEXT_ROWS, Math.floor(terminalHeight / 2));
return Math.max(0, Math.min(fullCap, baseline));
}
// The resolver never lets the overlay host past the fraction cap even when
// the transcript floor and every other zone have already given up their
// rows, so that cap — not just the prompt floor — bounds how much context
Expand Down Expand Up @@ -3554,6 +3570,12 @@ export interface OpenListOverlayOpts {
* server needs authorization.
*/
readonly echoChoice?: boolean;
/**
* Geometry mode for this open. Defaults to inset. Operator asks use
* full_shell so long questions and option lists stay readable; permission
* gates stay inset unless a caller opts in.
*/
readonly overlayMode?: "inset" | "full_shell";
/**
* Claim printable keys for a `>` filter row so the list narrows as you type.
* Opt-in per open (model picker, palette). Overlays without it keep j/k
Expand Down Expand Up @@ -3608,6 +3630,7 @@ export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): vo
onCancel: bag.overlayOnCancel,
addProviderHint: bag.overlayAddProviderHint,
setDefaultHint: bag.overlaySetDefaultHint,
overlayMode: bag.overlayMode === "full_shell" ? "full_shell" : "inset",
};
}
// Leave prior overlay focus frame; palette will stack above it.
Expand All @@ -3627,6 +3650,9 @@ export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): vo

const bag = internals.get(shell);
if (bag) {
// Mode must land before applyOverlayBodyText so decisionContextBudget can
// size the body against full_shell vs inset. Palette is always inset.
bag.overlayMode = !isPalette && opts?.overlayMode === "full_shell" ? "full_shell" : "inset";
// Palette open does not own primary accept; leave prior snapshot's callback.
if (!isPalette) {
bag.overlayItemIds = opts?.itemIds ? [...opts.itemIds] : [];
Expand Down Expand Up @@ -4105,6 +4131,7 @@ export function closeInsetOverlay(shell: AppShell): void {
bag.overlayOnCancel = prior.onCancel;
bag.overlayAddProviderHint = prior.addProviderHint;
bag.overlaySetDefaultHint = prior.setDefaultHint;
bag.overlayMode = prior.overlayMode === "full_shell" ? "full_shell" : "inset";
// If focus was not stacked (edge case), re-open overlay frame.
if (focusOwner(shell.focus) !== "overlay") {
shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, {
Expand All @@ -4116,7 +4143,7 @@ export function closeInsetOverlay(shell: AppShell): void {
const hostRows = overlayHostRows(shell, prior.bodyLines.length, listH);
const minHostRows = overlayMinHostRows(shell, prior.bodyLines.length, prior.list.count > 0);
relayout(shell, {
overlayMode: "inset",
overlayMode: bag.overlayMode,
overlayBodyRows: hostRows,
overlayMinBodyRows: minHostRows,
});
Expand Down Expand Up @@ -4225,8 +4252,10 @@ export function setOverlayBody(shell: AppShell, text: string, maxLines = 8): voi
shell.overlayBodyLines.length,
shell.overlayItems.length > 0,
);
const bag = internals.get(shell);
const mode: OverlayMode = bag?.overlayMode === "full_shell" ? "full_shell" : "inset";
relayout(shell, {
overlayMode: "inset",
overlayMode: mode,
overlayBodyRows: hostRows,
overlayMinBodyRows: minHostRows,
});
Expand Down
Loading