Skip to content
Open
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
37 changes: 37 additions & 0 deletions src/lib/agent/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ export type AgentConfig = {
getPendingQuestion?: () =>
| import('@lib/wizard-session').PendingQuestion
| null;
/**
* Optional live user guidance channel. TUI implementations resolve whenever
* the operator asks the agent to move on; non-interactive hosts omit it.
*/
waitForAgentNudge?: (
afterId?: number,
signal?: AbortSignal,
) => Promise<{ id: number; message: string } | null>;
/**
* Orchestrator queue context. Present only when the `wizard-orchestrator`
* flag routes the run here; threaded into wizard-tools so the orchestrator
Expand Down Expand Up @@ -299,6 +307,10 @@ type AgentRunConfig = {
getPendingQuestion?: () =>
| import('@lib/wizard-session').PendingQuestion
| null;
waitForAgentNudge?: (
afterId?: number,
signal?: AbortSignal,
) => Promise<{ id: number; message: string } | null>;
};

/**
Expand Down Expand Up @@ -712,6 +724,7 @@ export async function initializeAgent(
allowedTools: config.allowedTools,
disallowedTools: config.disallowedTools,
getPendingQuestion: config.getPendingQuestion,
waitForAgentNudge: config.waitForAgentNudge,
};

logToFile('Agent config:', {
Expand Down Expand Up @@ -833,6 +846,30 @@ export async function runAgent(
message: { role: 'user', content: prompt },
parent_tool_use_id: null,
};
let lastNudgeId = 0;
while (agentConfig.waitForAgentNudge) {
const nudgeAbort = new AbortController();
const nextNudge = agentConfig.waitForAgentNudge(
lastNudgeId,
nudgeAbort.signal,
);
const next = await Promise.race([
resultReceived.then(() => {
nudgeAbort.abort();
return null;
}),
nextNudge,
]);
if (!next) return;
lastNudgeId = next.id;
logToFile(`[agent] user nudge sent: ${next.message}`);
yield {
type: 'user',
session_id: '',
message: { role: 'user', content: next.message },
parent_tool_use_id: null,
};
}
await resultReceived;
};

Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent/runner/harness/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export const anthropicBackend: AgentHarness = {
allowedTools: programConfig.allowedTools,
disallowedTools: programConfig.disallowedTools,
getPendingQuestion: () => session.pendingQuestion,
waitForAgentNudge: (afterId, signal) =>
getUI().waitForAgentNudge(afterId, signal),
modelOverride: model,
},
sessionToOptions(session),
Expand Down Expand Up @@ -133,6 +135,8 @@ export const anthropicBackend: AgentHarness = {
wizardMetadata: boot.wizardMetadata,
integrationLabel: programConfig.id,
orchestrator,
waitForAgentNudge: (afterId, signal) =>
getUI().waitForAgentNudge(afterId, signal),
},
options,
);
Expand Down
10 changes: 10 additions & 0 deletions src/ui/logging-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export class LoggingUI implements WizardUI {
console.log(`◇ ${message}`);
}

waitForAgentNudge(
_afterId?: number,
signal?: AbortSignal,
): Promise<{ id: number; message: string } | null> {
if (signal?.aborted) return Promise.resolve(null);
return new Promise((resolve) => {
signal?.addEventListener('abort', () => resolve(null), { once: true });
});
}

setDetectedFramework(label: string): void {
console.log(`✔ Framework: ${label}`);
}
Expand Down
41 changes: 41 additions & 0 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,47 @@ describe('WizardStore', () => {
});
});

describe('agent nudges', () => {
it('records a nudge with a monotonic id and status message', () => {
const store = createStore();

store.nudgeAgent('Move to the next task.');

expect(store.agentNudge).toEqual({
id: 1,
message: 'Move to the next task.',
});
expect(store.statusMessages).toEqual(['Sent a nudge to the agent.']);
expect(wizardCaptureMock).toHaveBeenCalledWith(
'agent nudged',
expect.objectContaining({ program_id: Program.PostHogIntegration }),
);
});

it('waits for the next nudge after the given id', async () => {
const store = createStore();
store.nudgeAgent('First nudge');

const next = store.waitForAgentNudge(1);
store.nudgeAgent('Second nudge');

await expect(next).resolves.toEqual({
id: 2,
message: 'Second nudge',
});
});

it('resolves null when the wait is aborted', async () => {
const store = createStore();
const controller = new AbortController();

const next = store.waitForAgentNudge(0, controller.signal);
controller.abort();

await expect(next).resolves.toBeNull();
});
});

describe('tasks', () => {
it('setTasks replaces the task list', () => {
const store = createStore();
Expand Down
7 changes: 7 additions & 0 deletions src/ui/tui/ink-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ export class InkUI implements WizardUI {
this.store.pushStatus(message);
}

waitForAgentNudge(
afterId?: number,
signal?: AbortSignal,
): Promise<{ id: number; message: string } | null> {
return this.store.waitForAgentNudge(afterId, signal);
}

syncTodos(
todos: Array<{ content: string; status: string; activeForm?: string }>,
): void {
Expand Down
6 changes: 4 additions & 2 deletions src/ui/tui/primitives/TabContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ interface TabContainerProps {
expandableStatus?: boolean;
/** Store reference — required when expandableStatus is true so status state is shared. */
store?: WizardStore;
extraBindings?: KeyBinding[];
}

export const TabContainer = ({
tabs,
statusMessage,
expandableStatus = false,
store,
extraBindings = [],
}: TabContainerProps) => {
const [activeTab, setActiveTab] = useState(0);
// Fallback to local state when no store is provided
Expand Down Expand Up @@ -78,8 +80,8 @@ export const TabContainer = ({
},
});
}
return b;
}, [tabs.length, expandableStatus, store]);
return [...b, ...extraBindings];
}, [tabs.length, expandableStatus, store, extraBindings]);

useKeyBindings('tab-container', bindings);

Expand Down
14 changes: 14 additions & 0 deletions src/ui/tui/screens/RunScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
EventPlanViewer,
HNViewer,
} from '@ui/tui/primitives/index';
import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings';
import type { ProgressItem } from '@ui/tui/primitives/index';
import { ADDITIONAL_FEATURE_LABELS } from '@lib/wizard-session';
import { LearnCard } from '@ui/tui/components/LearnCard';
Expand Down Expand Up @@ -98,6 +99,18 @@ export const RunScreen = ({ store }: RunScreenProps) => {
// Program-supplied tips for the right pane; undefined falls back to
// DEFAULT_TIPS inside TipsCard, so non-self-driving programs are unaffected.
const programTips = getProgramConfig(activeProgram).getTips?.(store);
const nudgeBindings = useMemo<KeyBinding[]>(
() => [
{
match: 'n',
label: 'n',
action: 'nudge agent',
priority: 20,
handler: () => store.nudgeAgent(),
},
],
[store],
);

const leftPane = store.learnCardComplete ? (
<TipsCard store={store} tips={programTips} />
Expand Down Expand Up @@ -145,6 +158,7 @@ export const RunScreen = ({ store }: RunScreenProps) => {
tabs={tabs}
statusMessage={statuses}
expandableStatus
extraBindings={nudgeBindings}
store={store}
/>
);
Expand Down
16 changes: 15 additions & 1 deletion src/ui/tui/screens/audit/AuditRunScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSyncExternalStore } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import { join } from 'node:path';
import { Box } from 'ink';
import type { WizardStore } from '@ui/tui/store';
Expand All @@ -8,6 +8,7 @@ import {
LogViewer,
HNViewer,
} from '@ui/tui/primitives/index';
import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings';
import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions';
import { useFileWatcher } from '@ui/tui/hooks/file-watcher';
import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js';
Expand Down Expand Up @@ -63,6 +64,18 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => {
notebookUrl={store.session.notebookUrl}
/>
);
const nudgeBindings = useMemo<KeyBinding[]>(
() => [
{
match: 'n',
label: 'n',
action: 'nudge agent',
priority: 20,
handler: () => store.nudgeAgent(),
},
],
[store],
);

// Narrow terminals: drop the area pane.
const statusComponent =
Expand Down Expand Up @@ -94,6 +107,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => {
tabs={tabs}
statusMessage={statuses}
expandableStatus
extraBindings={nudgeBindings}
store={store}
/>
);
Expand Down
44 changes: 44 additions & 0 deletions src/ui/tui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export class WizardStore {
private $currentStage = atom<{ stage: string; startedAt: number } | null>(
null,
);
private $agentNudge = atom<{ id: number; message: string } | null>(null);
private $tokenUsage = atom<TokenUsageSnapshot>(EMPTY_TOKEN_USAGE);
// Defaults on for local/dev/test runs (tsx, `pnpm try`, vitest) so
// contributors see it without needing to know the shortcut; defaults off
Expand All @@ -199,6 +200,7 @@ export class WizardStore {
private $tokenHudVisible = atom(IS_DEV);

private _onTasksChanged: (() => void) | null = null;
private _nudgeSeq = 0;
/** Last screen seen — used to detect screen transitions for analytics. */
private _lastScreen: ScreenName | null = null;

Expand Down Expand Up @@ -390,6 +392,10 @@ export class WizardStore {
return this.$currentStage.get();
}

get agentNudge(): { id: number; message: string } | null {
return this.$agentNudge.get();
}

/** No-op when the stage hasn't changed, so `startedAt` survives across
* re-renders and tab switches and measures real stage time. */
setCurrentStage(stage: string): void {
Expand Down Expand Up @@ -910,6 +916,44 @@ export class WizardStore {
this.emitChange();
}

nudgeAgent(
message = 'Please finish the current line of work and move on if you are stuck.',
): void {
this._nudgeSeq += 1;
this.$agentNudge.set({ id: this._nudgeSeq, message });
this.emitChange();
this.pushStatus('Sent a nudge to the agent.');
analytics.wizardCapture('agent nudged', {
program_id: this.router.activeProgram,
...sessionProperties(this.session),
});
}

waitForAgentNudge(
afterId = 0,
signal?: AbortSignal,
): Promise<{ id: number; message: string } | null> {
const current = this.$agentNudge.get();
if (current && current.id > afterId) return Promise.resolve(current);
if (signal?.aborted) return Promise.resolve(null);
return new Promise((resolve) => {
let unsub: (() => void) | null = null;
const onAbort = () => {
unsub?.();
resolve(null);
};
signal?.addEventListener('abort', onAbort, { once: true });
unsub = this.subscribe(() => {
const next = this.$agentNudge.get();
if (next && next.id > afterId) {
unsub?.();
signal?.removeEventListener('abort', onAbort);
resolve(next);
}
});
});
}

get tokenUsage(): TokenUsageSnapshot {
return this.$tokenUsage.get();
}
Expand Down
8 changes: 8 additions & 0 deletions src/ui/wizard-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ export interface WizardUI {
note(message: string): void;
pushStatus(message: string): void;

/** User-triggered guidance for an active agent run. TUI-only callers emit it
* into the live prompt stream; non-interactive implementations may never
* resolve. */
waitForAgentNudge(
afterId?: number,
signal?: AbortSignal,
): Promise<{ id: number; message: string } | null>;

// ── Spinner ───────────────────────────────────────────────────────
spinner(): SpinnerHandle;

Expand Down