Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
73 changes: 65 additions & 8 deletions apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import React from "react";
import { Box, Text, useStdout } from "ink";
import type { PendingApproval } from "../types";
import { theme } from "../theme";
import type { AdeCodeProvider } from "../types";
import { useHoveredHitId } from "../hitTestRegistry";
import { SectionHeader, Pill, StatusDot, KeyHints, Rule } from "./designKit";
import {
optionsForPendingQuestion,
pendingQuestionAnsweredCount,
type PendingQuestionSelectionState,
} from "../pendingInput";
import { pendingInputHeaderLabel } from "../../../../desktop/src/shared/pendingInputLabels";

// Default inner widths. The parent never passes a width, so the card sizes
// itself: a fixed, centered column when modal (high-stakes), and a comfortable
Expand All @@ -22,6 +24,28 @@ function truncateEnd(value: string, max: number): string {
return `${value.slice(0, Math.max(0, max - 1))}…`;
}

// Split a preview into individual lines so the terminal renders them verbatim,
// preserving column alignment (diffs, box-drawing, indented plans). Each line is
// truncated to the card width rather than soft-wrapped mid-box — a wrap would
// reflow box-drawing columns onto the next row and break the layout. A cap keeps
// a giant plan/diff from blowing past the card; the truncation marker tells the
// user there's more.
const PREVIEW_MAX_LINES = 12;
const PROVIDER_ACCENT_SOURCES = new Set(["claude", "codex", "cursor", "droid", "opencode", "ollama", "lmstudio"]);

function previewLines(value: string, max: number): string[] {
const rows = value.replace(/\r\n?/g, "\n").split("\n");
const clipped = rows.slice(0, PREVIEW_MAX_LINES).map((row) => truncateEnd(row, max));
if (rows.length > PREVIEW_MAX_LINES) clipped.push("…");
return clipped;
}

function pendingInputAccent(source: string | null | undefined): string {
const normalized = source?.trim().toLowerCase();
if (!normalized || !PROVIDER_ACCENT_SOURCES.has(normalized)) return theme.color.violet;
return theme.provider(normalized as AdeCodeProvider).color;
}

/**
* An access-key prefix shown immediately before an action's pill, e.g. the `a`
* in `a [ approve ]`. Accentuated when the action is highlighted.
Expand Down Expand Up @@ -61,25 +85,34 @@ export function ApprovalPrompt({
const highStakes = approval.highStakes;
const isQuestion = approval.mode === "question";
const kind = approval.request?.kind;
const source = approval.request?.source;
const providerAccent = pendingInputAccent(source);

// Accent + glyph for the titled header. Green is never used here — amber for a
// routine permission ask, red for a high-stakes/destructive one, violet for
// selection/input requests (informational, not a warning).
//
// Question + plan_approval headers read as the source-derived
// `pendingInputHeaderLabel` ("Claude asks" / "Claude · Plan ready"), matching
// the desktop redesign — no more kind-based "INPUT REQUESTED" double-labelling.
let title: string;
let glyph: string;
let accent: string;
if (kind === "model_selection") {
title = "MODEL SELECTION";
glyph = "◆";
accent = theme.color.violet;
accent = providerAccent;
} else if (kind === "plan_approval") {
title = "PLAN APPROVAL";
title = pendingInputHeaderLabel(source, kind);
glyph = "◇";
accent = theme.color.violet;
accent = providerAccent;
} else if (isQuestion) {
title = "INPUT REQUESTED";
// `kind` may be undefined (legacy/freeform questions); the helper's default
// branch ("{Provider} asks") is exactly right for any question kind, so
// coalesce to "" rather than special-casing.
title = pendingInputHeaderLabel(source, kind ?? "");
glyph = "?";
accent = theme.color.violet;
accent = providerAccent;
} else if (highStakes) {
title = "HIGH-STAKES PERMISSION";
glyph = "▲";
Expand Down Expand Up @@ -114,6 +147,18 @@ export function ApprovalPrompt({
: null;
const secondary = secondaryRaw && secondaryRaw !== primary ? secondaryRaw : null;

// For questions, the request-level description is shown as a single context
// kicker above the questions — but ONLY when it adds detail beyond the question
// text itself, otherwise it's the header + question + same-question-again
// duplication the desktop redesign removed.
const requestDescription = approval.request?.description?.trim() ?? "";
const extraContext =
isQuestion
&& requestDescription
&& !questions.some((entry) => entry.question.trim() === requestDescription)
? requestDescription
: null;

const showChips = !isQuestion && !highStakes;

// Footer hints, keyed to the real bindings in app input handling.
Expand Down Expand Up @@ -154,6 +199,14 @@ export function ApprovalPrompt({
</Text>
) : null}

{extraContext ? (
<Box marginTop={1}>
<Text color={theme.color.t3} dimColor wrap="truncate-end">
{truncateEnd(extraContext, textWidth)}
</Text>
</Box>
) : null}

{primary && !isQuestion ? (
<Box marginTop={modal ? 1 : 0}>
<Text color={theme.color.t1} wrap="truncate-end">
Expand Down Expand Up @@ -208,9 +261,13 @@ export function ApprovalPrompt({
</Text>
</Box>
{option.preview && active ? (
<Text color={theme.color.t3} dimColor wrap="truncate-end">
{truncateEnd(option.preview, Math.max(8, textWidth - 2))}
</Text>
<Box flexDirection="column">
{previewLines(option.preview, Math.max(8, textWidth - 2)).map((previewLine, previewIndex) => (
<Text key={previewIndex} color={theme.color.t3} dimColor wrap="truncate-end">
{previewLine.length ? previewLine : " "}
</Text>
))}
</Box>
) : null}
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,7 @@ describe("runtime lane snapshot actions", () => {
sessionId: "awaiting-chat",
status: "active",
awaitingInput: true,
pendingInputItemId: "pending-1",
identityKey: null,
},
]),
Expand Down Expand Up @@ -1007,6 +1008,7 @@ describe("runtime lane snapshot actions", () => {
bucket: "awaiting-input",
runningCount: 1,
awaitingInputCount: 1,
pendingInputCount: 1,
endedCount: 1,
sessionCount: 3,
},
Expand All @@ -1017,11 +1019,15 @@ describe("runtime lane snapshot actions", () => {
adoptableAttached: false,
},
{
lane: attachedLane,
lane: {
...attachedLane,
devicesOpen: undefined,
},
runtime: {
bucket: "running",
runningCount: 1,
awaitingInputCount: 0,
pendingInputCount: 0,
endedCount: 0,
sessionCount: 1,
},
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/main/services/chat/droidSdkAskUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { summarizeDroidAskUser } from "./droidSdkAskUser";

describe("summarizeDroidAskUser", () => {
it("preserves raw Droid option values while trimming display labels", () => {
const request = summarizeDroidAskUser({
toolCallId: "tool-ask-1",
questions: [
{
index: 7,
topic: " Deploy choice ",
question: "Which exact option should Droid receive?",
options: [" yes ", "no", "", " "],
},
],
} as any);

expect(request.questions).toEqual([
{
id: "q_7",
header: "Deploy choice",
question: "Which exact option should Droid receive?",
options: [
{ label: "yes", value: " yes " },
{ label: "no", value: "no" },
],
},
]);
});
});
32 changes: 32 additions & 0 deletions apps/desktop/src/main/services/chat/droidSdkAskUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type * as DroidSdkTypes from "@factory/droid-sdk";
import type { DroidSdkAskUserRequest } from "./droidSdkProtocol";

// Droid's ask-user payload (`AskUserQuestion`) exposes only `topic`, `question`,
// and `options: string[]`; there is no per-option description, no multiSelect,
// and no default-value field in the SDK schema (the zod object is "passthrough",
// but no richer fields are documented or emitted). We trim display labels, but
// keep raw option strings as values because Droid expects exact answer text.
export function summarizeDroidAskUser(params: DroidSdkTypes.AskUserRequestParams): DroidSdkAskUserRequest {
const questions = (params.questions ?? []).map((question, index) => {
const topic = typeof question.topic === "string" ? question.topic.trim() : "";
const options = (question.options ?? [])
.filter((option): option is string => typeof option === "string" && option.trim().length > 0)
.map((option) => ({
label: option.trim() || option,
value: option,
}));
return {
id: `q_${question.index ?? index + 1}`,
...(topic.length ? { header: topic } : {}),
question: question.question,
...(options.length ? { options } : {}),
};
});
return {
id: params.toolCallId || `droid-ask-user-${Date.now()}`,
toolCallId: params.toolCallId,
title: questions.length === 1 ? "Question from Droid" : "Questions from Droid",
questions,
raw: params,
};
}
9 changes: 9 additions & 0 deletions apps/desktop/src/main/services/chat/droidSdkProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,23 @@ export type DroidSdkPermissionDecision = {
comment?: string;
};

// Mirrors the Droid SDK `AskUserQuestion` shape (@factory/droid-sdk
// `AskUserRequestParamsSchema`). The SDK exposes exactly `topic`, `question`,
// and `options: string[]` per question — there is no per-option description,
// no multiSelect/allowMultiple flag, and no default-value field, so options are
// surfaced as bare choices and the topic becomes the question header. Display
// labels may be trimmed, but values preserve Droid's original option strings.
// This is the full ceiling of the Droid ask-user contract.
export type DroidSdkAskUserRequest = {
id: string;
toolCallId: string;
title: string;
questions: Array<{
id: string;
/** Droid's per-question `topic` (short label), surfaced as the card header. */
header?: string;
question: string;
/** Droid options are plain strings; value preserves the exact SDK string. */
options?: Array<{ label: string; value: string }>;
}>;
raw: unknown;
Expand Down
20 changes: 2 additions & 18 deletions apps/desktop/src/main/services/chat/droidSdkWorker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type * as DroidSdkTypes from "@factory/droid-sdk";
import type {
DroidSdkAskUserRequest,
DroidSdkAskUserResponse,
DroidSdkPermissionDecision,
DroidSdkPermissionRequest,
Expand All @@ -12,6 +11,7 @@ import type {
DroidSdkWorkerResponse,
} from "./droidSdkProtocol";
import { loadDroidSdk } from "../ai/droidSdkLoader";
import { summarizeDroidAskUser } from "./droidSdkAskUser";

type DroidSdkModule = typeof DroidSdkTypes;
type DroidSession = Awaited<ReturnType<DroidSdkModule["createSession"]>>;
Expand Down Expand Up @@ -196,24 +196,8 @@ async function requestPermission(
};
}

function summarizeAskUser(params: DroidSdkTypes.AskUserRequestParams): DroidSdkAskUserRequest {
const questions = (params.questions ?? []).map((question, index) => ({
id: `q_${question.index ?? index + 1}`,
header: question.topic,
question: question.question,
options: question.options?.map((option) => ({ label: option, value: option })),
}));
return {
id: params.toolCallId || `droid-ask-user-${Date.now()}`,
toolCallId: params.toolCallId,
title: questions.length === 1 ? "Question from Droid" : "Questions from Droid",
questions,
raw: params,
};
}

async function requestAskUser(params: DroidSdkTypes.AskUserRequestParams): Promise<DroidSdkTypes.AskUserResult> {
const request = summarizeAskUser(params);
const request = summarizeDroidAskUser(params);
const waiterId = nextWaiterId("droid-ask-user");
const requestWithId = { ...request, id: waiterId };
const response = await new Promise<DroidSdkAskUserResponse>((resolve) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,87 @@ describe("laneListSnapshotService", () => {
bucket: "awaiting-input",
runningCount: 0,
awaitingInputCount: 1,
pendingInputCount: 0,
endedCount: 0,
sessionCount: 1,
});
});

it("counts chat pending input separately from CLI attention heuristics", async () => {
const services = {
...makeHarness({
id: "chat-1",
laneId: "lane-1",
status: "running",
runtimeState: "idle",
toolType: "cursor-chat",
lastOutputPreview: "Turn completed",
}),
agentChatService: {
listSessions: vi.fn(() => [{
sessionId: "chat-1",
laneId: "lane-1",
status: "active",
awaitingInput: true,
pendingInputItemId: "pending-1",
}]),
},
};

const [snapshot] = await buildLaneListSnapshots(
services as any,
[{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any,
{
includeConflictStatus: false,
includeRebaseSuggestions: false,
includeAutoRebaseStatus: false,
},
);

expect(snapshot?.runtime).toMatchObject({
bucket: "awaiting-input",
awaitingInputCount: 1,
pendingInputCount: 1,
});
});

it("does not count stale chat awaiting state without a pending item id", async () => {
const services = {
...makeHarness({
id: "chat-1",
laneId: "lane-1",
status: "running",
runtimeState: "idle",
toolType: "cursor-chat",
lastOutputPreview: "Turn completed",
}),
agentChatService: {
listSessions: vi.fn(() => [{
sessionId: "chat-1",
laneId: "lane-1",
status: "active",
awaitingInput: true,
}]),
},
};

const [snapshot] = await buildLaneListSnapshots(
services as any,
[{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any,
{
includeConflictStatus: false,
includeRebaseSuggestions: false,
includeAutoRebaseStatus: false,
},
);

expect(snapshot?.runtime).toMatchObject({
bucket: "awaiting-input",
awaitingInputCount: 1,
pendingInputCount: 0,
});
});

it("keeps idle shell sessions in the running bucket", async () => {
const services = makeHarness({
laneId: "lane-1",
Expand All @@ -70,6 +146,7 @@ describe("laneListSnapshotService", () => {
bucket: "running",
runningCount: 1,
awaitingInputCount: 0,
pendingInputCount: 0,
endedCount: 0,
sessionCount: 1,
});
Expand Down
Loading