Skip to content
Draft
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
41 changes: 41 additions & 0 deletions docs/design/herdr-tui-reporter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Herdr TUI Reporter

## Context

Herdr can identify Qwen Code and persist its session through the integration
added in herdrdev/herdr#2743, but screen matching can miss localized working and
approval states. Qwen Code already owns the exact TUI state, so the interactive
client should report it directly when Herdr launches the pane.

## Design

The interactive UI creates one fail-open reporter only when Herdr's pane,
socket, and binary environment variables are present. It reports session IDs
through the existing `herdr:qwen` source and TUI lifecycle through the
`qwen-code:tui` source. This requires Herdr to treat those exact sources as one
Qwen owner while keeping their sequence numbers and release behavior
independent. That paired ownership contract is tracked in
[herdrdev/herdr#2757](https://github.com/herdrdev/herdr/discussions/2757).

The reported state is `blocked` for authentication or an active tool,
integration, or skill confirmation, `working` while the model, tools, or a
slash command are running, and `idle` otherwise. Reports are serialized and
deduplicated. A newer pending state replaces one that has not started. Command
failures and timeouts never affect the UI.

Graceful exit drains the active report and releases only `qwen-code:tui`.
Herdr then falls back to screen detection while retaining the official session
identity. Process detection remains the crash fallback.

## Boundaries

This is TUI status integration, not an orchestration API. It does not change
core, ACP, headless mode, Agent Team, or how Qwen delegates to Codex, Pi, and
other CLIs through Herdr.

## Verification

Focused tests cover environment gating, report ordering and deduplication,
session changes, monotonic sequences, failures, and release. A live Herdr pane
must exercise idle, working, approval-blocked, session switch, and graceful
exit states.
45 changes: 43 additions & 2 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ import { useKeypress, type Key } from './hooks/useKeypress.js';
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
import { clearCiEnv } from '../test-utils/ci-env.js';
import { restorePromptStash } from '../services/prompt-stash.js';
import type { HerdrReporter } from '../utils/herdr-reporter.js';

describe('AppContainer State Management', () => {
let mockConfig: Config;
Expand Down Expand Up @@ -439,7 +440,7 @@ describe('AppContainer State Management', () => {
vimMode: 'NORMAL',
});
mockedUseSessionStats.mockReturnValue({
stats: {},
stats: { sessionId: 'session-1' },
seedPromptCount: vi.fn(),
});
mockedUseTextBuffer.mockReturnValue({
Expand Down Expand Up @@ -545,6 +546,41 @@ describe('AppContainer State Management', () => {
vi.useRealTimers();
});

it('reports blocked TUI state to Herdr', async () => {
const report = vi.fn();
const herdrReporter = { report } as unknown as HerdrReporter;
mockedUseFolderTrust.mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
mockedUseGeminiStream.mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
clearPendingState: mockClearPendingState,
});

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
herdrReporter={herdrReporter}
/>,
);
await vi.waitFor(() =>
expect(report).toHaveBeenLastCalledWith('session-1', 'blocked'),
);
});

const rewindUserItem = (
id: number,
text: string,
Expand Down Expand Up @@ -5512,7 +5548,8 @@ describe('AppContainer State Management', () => {
});

describe('Keyboard Input Handling', () => {
it('should block quit command during authentication', () => {
it('should block quit command during authentication', async () => {
const report = vi.fn();
mockedUseAuthCommand.mockReturnValue({
authState: 'unauthenticated',
setAuthState: vi.fn(),
Expand Down Expand Up @@ -5569,10 +5606,14 @@ describe('AppContainer State Management', () => {
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
herdrReporter={{ report } as unknown as HerdrReporter}
/>,
);

expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit');
await vi.waitFor(() =>
expect(report).toHaveBeenLastCalledWith('session-1', 'blocked'),
);
});

it('should prevent exit command when text buffer has content', () => {
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ import { getLiveAgentPanelLayoutKey } from './components/background-view/liveAge
import { t } from '../i18n/index.js';
import { TUI_CHAT_RECORDING_FAILURE_MESSAGE } from '../nonInteractive/chat-recording-failure.js';
import { buildPermissionSuggestions } from '../nonInteractive/permission-suggestions.js';
import type {
HerdrAgentState,
HerdrReporter,
} from '../utils/herdr-reporter.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
Expand Down Expand Up @@ -705,6 +709,7 @@ interface AppContainerProps {
* stays write-free (static remount bump only), matching pre-PR behavior.
*/
repaintViewport?: () => void;
herdrReporter?: HerdrReporter | null;
}

/**
Expand All @@ -726,6 +731,7 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult,
initialUseVirtualViewport,
repaintViewport,
herdrReporter,
} = props;
const extensionRefreshState = useMemo(
() => props.extensionRefreshState ?? new ExtensionRefreshState(),
Expand Down Expand Up @@ -3563,6 +3569,33 @@ export const AppContainer = (props: AppContainerProps) => {
history: historyManager.history,
sessionStats,
});
const herdrBlocked =
streamingState === StreamingState.WaitingForConfirmation ||
!!isFolderTrustDialogOpen ||
isMcpApprovalDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!providerUpdateRequest ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isAuthDialogOpen ||
isAuthenticating ||
(isSkillReviewDialogOpen && !!skillReviewPending) ||
showWelcomeBackDialog ||
shouldShowIdePrompt ||
shouldShowCommandMigrationNudge ||
showIdeRestartPrompt;
const herdrState: HerdrAgentState = herdrBlocked
? 'blocked'
: streamingState === StreamingState.Responding || isProcessing
? 'working'
: 'idle';
useEffect(() => {
herdrReporter?.report(sessionStats.sessionId, herdrState);
}, [herdrReporter, herdrState, sessionStats.sessionId]);

const dialogsVisible =
showWelcomeBackDialog ||
shouldShowIdePrompt ||
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/ui/startInteractiveUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { sanitizeTerminalText } from './utils/textUtils.js';
import { startPostRenderPrefetches } from '../startup/startup-prefetch.js';
import { computeWindowTitle, writeTerminalTitle } from './utils/windowTitle.js';
import { getCliVersion } from '../utils/version.js';
import { createHerdrReporter } from '../utils/herdr-reporter.js';

const debugLogger = createDebugLogger('STARTUP');

Expand Down Expand Up @@ -103,6 +104,11 @@ export async function startInteractiveUI(
// ignored: best-effort, never block UI startup.
}

const herdrReporter = createHerdrReporter();
if (herdrReporter) {
registerCleanup(() => herdrReporter.release());
}

const restoreTerminalRedrawOptimizer =
process.stdout.isTTY && !config.getScreenReader()
? installTerminalRedrawOptimizer(process.stdout)
Expand Down Expand Up @@ -238,6 +244,7 @@ export async function startInteractiveUI(
initialUseVirtualViewport={useVP}
extensionRefreshState={options.extensionRefreshState}
repaintViewport={resizeReflow.repaint}
herdrReporter={herdrReporter}
/>
</BackgroundTaskViewProvider>
</AgentViewProvider>
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/utils/herdr-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it, vi } from 'vitest';
import { createHerdrReporter, HerdrReporter } from './herdr-reporter.js';

describe('HerdrReporter', () => {
it('is disabled outside a Herdr pane', () => {
expect(createHerdrReporter({})).toBeNull();
expect(
createHerdrReporter({
HERDR_ENV: '1',
HERDR_PANE_ID: 'w1:p1',
HERDR_BIN_PATH: '/bin/herdr',
}),
).toBeNull();
});

it('serializes session, latest state, session changes, and release', async () => {
const calls: string[][] = [];
let resumeFirst: (() => void) | undefined;
const first = new Promise<void>((resolve) => {
resumeFirst = resolve;
});
const run = vi.fn(async (args: readonly string[]) => {
calls.push([...args]);
if (calls.length === 1) await first;
});
const reporter = new HerdrReporter('w1:p1', run);

reporter.report('session-1', 'idle');
reporter.report('session-1', 'working');
await vi.waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toContain('report-agent-session');
expect(calls[0]).not.toContain('--seq');

resumeFirst?.();
await vi.waitFor(() => expect(calls).toHaveLength(2));
expect(calls[1]).toContain('working');
reporter.report('session-1', 'working');
await Promise.resolve();
expect(calls).toHaveLength(2);

reporter.report('session-2', 'working');
await vi.waitFor(() => expect(calls).toHaveLength(3));
expect(calls[2]).toContain('session-2');
expect(calls[2]).toContain('clear');

await reporter.release();
expect(calls).toHaveLength(4);
expect(calls[3]).toContain('release-agent');
reporter.report('session-2', 'blocked');
await Promise.resolve();
expect(calls).toHaveLength(4);

const stateSeq = BigInt(calls[1]![calls[1]!.indexOf('--seq') + 1]!);
const releaseSeq = BigInt(calls[3]![calls[3]!.indexOf('--seq') + 1]!);
expect(releaseSeq).toBeGreaterThan(stateSeq);
});

it('swallows transport failures', async () => {
const reporter = new HerdrReporter('w1:p1', async () => {
throw new Error('offline');
});

reporter.report('session-1', 'working');
await expect(reporter.release()).resolves.toBeUndefined();
});
});
Loading
Loading