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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@
session. If that session is gone by the time you come back, the app falls back
to the first tab rather than showing you nothing.

- **The agent can ask you a question and wait for the answer.** Until now the
only thing it could put in front of you was an approval — allow or deny a tool
it was about to run. Anything else it needed to know, it had to ask in prose,
and you had to guess the wording it was hoping for.

It can now ask a proper question with the answers already written out: which
of three approaches to take, which of the four candidate files you meant, which
of the problems it found to fix first. The question appears in the conversation
where it was asked, you answer by clicking, and the agent picks up from your
answer. Questions come in both kinds — pick exactly one, or tick several and
confirm.

The card stays where it was after you answer, showing what was asked and what
you chose, so scrolling back past a decision shows the decision. If you close
the tab while one is waiting, it is still there — and still answerable — when
you come back. A question you would rather not answer can be skipped; the agent
is told so and carries on rather than sitting there blocked.

Questions are asked even in sessions running with approvals bypassed: not being
asked before it acts has never meant having your questions answered for you.

Works with Claude and with omp, each through its own handshake. kimi can reach
it too, but often prefers its own built-in question tool, which answers itself
without asking anyone — so questions there are hit and miss, and that is kimi's
behaviour rather than something this app can steer. Codex, pi and grok report
the capability as unavailable rather than offering a button that would do
nothing.
- **You can open what the agent handed off and watch it work.** A delegation
used to be one line in the agents list: a name, a status badge, a duration.
Whether it was a sub-agent reading three files or a workflow running a dozen
Expand Down
12 changes: 12 additions & 0 deletions src/client/chat/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ export class ChatController {
this.send({ type: 'chat_queue_cancel', queuedId });
}

/**
* Answer a multiple-choice question the model asked.
*
* `skipped` is explicit rather than inferred from an empty list: "I picked
* none of these" and "I do not want to answer" reach the model as different
* sentences, and the agent is blocked either way until one of them arrives.
*/
answerQuestion(requestId: string, optionIds: string[], skipped = false): void {
this.send({ type: 'chat_question_answer', requestId, optionIds, skipped });
}

respondPermission(requestId: string, optionId: string): void {
this.send({ type: 'chat_permission_response', requestId, optionId });
}
Expand Down Expand Up @@ -415,6 +426,7 @@ export class ChatController {
state: 'starting',
capabilities: NO_CHAT_CAPABILITIES,
pendingPermissions: [],
pendingQuestions: [],
firstSeq: 0,
replayFrom: 0,
cursor: 0,
Expand Down
23 changes: 23 additions & 0 deletions src/client/chat/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PermissionRequest,
PlanItem,
QueuedTurn,
QuestionRequest,
NO_CHAT_CAPABILITIES,
} from '../../shared/chat-events.js';
import {
Expand Down Expand Up @@ -161,6 +162,7 @@ export class ChatTranscript {
usage: snapshot.usage || {},
plan: snapshot.plan || [],
pendingPermissions: snapshot.pendingPermissions || [],
pendingQuestions: snapshot.pendingQuestions || [],
firstSeq: snapshot.firstSeq,
cursor: snapshot.cursor,
});
Expand Down Expand Up @@ -371,6 +373,27 @@ export class ChatTranscript {
return this.state.pendingPermissions;
}

/** Questions the model asked that nobody has answered yet. */
get pendingQuestions(): QuestionRequest[] {
return this.state.pendingQuestions;
}

/**
* The question waiting on the given tool call, if that call is the one asking.
*
* How a card drawn from a tool block finds out it is still live: the block is
* in the transcript either way, and this is the difference between a set of
* buttons and a record of what was already decided.
*/
questionFor(toolId: string): QuestionRequest | undefined {
return this.state.pendingQuestions.find((pending) => pending.toolId === toolId);
}

/** Which options were picked for the question that call asked, once answered. */
answerFor(toolId: string): string[] | undefined {
return this.state.answeredQuestions[toolId];
}

get cursor(): number {
return this.state.cursor;
}
Expand Down
3 changes: 2 additions & 1 deletion src/client/chat/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ function summarise(
}
}

const status: TurnStatus = isLast && chatState === 'awaiting_permission'
const status: TurnStatus = isLast
&& (chatState === 'awaiting_permission' || chatState === 'awaiting_answer')
? 'waiting'
: isLast && (streaming || chatState === 'thinking' || chatState === 'running' || chatState === 'starting')
? 'running'
Expand Down
18 changes: 16 additions & 2 deletions src/client/shell/chat/ChatSessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ export interface ChatSessionSidebarProps {
}

/** States where the agent is actively doing something, as opposed to waiting. */
const BUSY_STATES: ReadonlySet<ChatState> = new Set(['starting', 'thinking', 'running', 'awaiting_permission']);
const BUSY_STATES: ReadonlySet<ChatState> = new Set([
'starting',
'thinking',
'running',
'awaiting_permission',
'awaiting_answer',
]);

function stateDotColor(state: ChatState | undefined): string {
if (state === 'error') return 'var(--destructive)';
if (state === 'awaiting_permission') return 'var(--warning)';
if (state === 'awaiting_answer') return 'var(--info)';
if (state && BUSY_STATES.has(state)) return 'var(--ansi-green)';
return 'var(--muted-foreground)';
}
Expand All @@ -64,6 +71,7 @@ function stateDotColor(state: ChatState | undefined): string {
function stateLabel(state: ChatState | undefined): string | undefined {
switch (state) {
case 'awaiting_permission': return 'needs approval';
case 'awaiting_answer': return 'asked you something';
case 'error': return 'error';
case 'running': return 'running';
case 'thinking': return 'thinking';
Expand Down Expand Up @@ -321,7 +329,13 @@ function SessionRow({
</Tooltip>
{label ? (
<Badge
variant={session.state === 'error' ? 'destructive' : session.state === 'awaiting_permission' ? 'warning' : 'neutral'}
variant={
session.state === 'error'
? 'destructive'
: session.state === 'awaiting_permission'
? 'warning'
: 'neutral'
}
style={{ height: 16, padding: '0 5px', fontSize: 'var(--text-2xs)', flex: '0 0 auto' }}
>
{label}
Expand Down
61 changes: 59 additions & 2 deletions src/client/shell/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Composer } from './Composer.js';
import { MessageList, type MessageListHandle } from './MessageList.js';
import { hasVisibleContent, messageText } from './MessageBubble.js';
import { PermissionCard } from './PermissionCard.js';
import { QuestionCard } from './QuestionCard.js';
import { PlanPanel } from './PlanPanel.js';
import { SessionHeader } from './SessionHeader.js';
import { LiveStreamRibbon } from './StreamRibbon.js';
Expand Down Expand Up @@ -152,6 +153,19 @@ export function ChatView({
const bypassPermissions = transcript.bypassing;
const plan = transcript.plan;
const pending = transcript.pendingPermissions;
// Only the questions that have nowhere else to be drawn. A question that
// names the call that asked it renders inside the conversation at that call,
// which is where it was asked; this is the safety net for one that could not
// be correlated, and without it that question would have no button anywhere
// and the turn behind it would never move.
const strayQuestions = React.useMemo(
() =>
transcript.pendingQuestions.filter(
(request) => !request.toolId || !transcript.message(messageIdOfTool(transcript, request.toolId)),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[transcript, version],
);
const exited = chatState === 'exited';
const unavailable = controller.unavailableReason;
const busy = transcript.busy;
Expand Down Expand Up @@ -304,6 +318,11 @@ export function ChatView({
(requestId: string, optionId: string) => controller.respondPermission(requestId, optionId),
[controller],
);
const answerQuestion = React.useCallback(
(requestId: string, optionIds: string[], skipped: boolean) =>
controller.answerQuestion(requestId, optionIds, skipped),
[controller],
);
const cancelQueued = React.useCallback(
(queuedId: string) => controller.cancelQueued(queuedId),
[controller],
Expand Down Expand Up @@ -673,6 +692,7 @@ export function ChatView({
onRetry={retryTurn}
showThinking={view.showThinking}
showToolCalls={view.showToolCalls}
onAnswerQuestion={answerQuestion}
/>

{terminalOpen ? (
Expand Down Expand Up @@ -706,7 +726,7 @@ export function ChatView({
/>
) : null}

{busy || chatState === 'awaiting_permission' || chatState === 'error' ? (
{busy || chatState === 'awaiting_permission' || chatState === 'awaiting_answer' || chatState === 'error' ? (
<LiveStreamRibbon
transcript={transcript}
turn={currentTurn}
Expand Down Expand Up @@ -801,6 +821,27 @@ export function ChatView({
</div>
) : null}

{strayQuestions.length > 0 ? (
<div
role="region"
aria-label="Questions from the assistant"
aria-live="assertive"
style={{ display: 'grid', gap: 'var(--space-2)', maxHeight: '50vh', overflowY: 'auto' }}
>
{strayQuestions.map((request) => (
<QuestionCard
key={request.requestId}
request={request}
question={request.question}
header={request.header}
multiSelect={request.multiSelect}
options={request.options}
onAnswer={answerQuestion}
/>
))}
</div>
) : null}

{pending.length > 0 ? (
// assertive, not polite: nothing else the user does will move the
// session forward until one of these is answered.
Expand Down Expand Up @@ -952,7 +993,7 @@ function placeholderFor(state: ChatState, runtimeLabel: string, isPhone = false)
if (state === 'exited') return 'This session has ended';
// No longer 'answer this before you can type': the composer stays live and
// queues, so the sentence has to describe what happens rather than forbid it.
if (state === 'awaiting_permission') {
if (state === 'awaiting_permission' || state === 'awaiting_answer') {
return isPhone ? 'Answer above, or type' : 'Answer above — or type the next message';
}
if (state === 'thinking' || state === 'running') {
Expand Down Expand Up @@ -1263,3 +1304,19 @@ function mobileBarHeight(): number {
return Number.isFinite(parsed) ? parsed : MOBILE_BAR_FALLBACK_PX;
}

/**
* Which message holds a given tool call, or '' when the transcript has none.
*
* Used only to decide whether a pending question already has a card inside the
* conversation. A question whose call is above the loaded window — or whose call
* never arrived — needs the pinned card instead, and asking the transcript is
* the only way to tell those apart from one that is on screen.
*/
function messageIdOfTool(transcript: ChatTranscript, toolId: string): string {
for (const message of transcript.messages) {
for (const block of message.blocks) {
if (block.kind === 'tool' && block.toolId === toolId) return message.id;
}
}
return '';
}
Loading
Loading