Compare commits

..

11 Commits

Author SHA1 Message Date
Keith Guerin bc814bdfe6 fix(cli): improve type safety and fix StatusRow tests 2026-03-27 22:39:12 -07:00
Keith Guerin bc17679db7 feat(cli): suppress shell inactivity indicators when cursor is hidden 2026-03-27 22:39:12 -07:00
Keith Guerin 5a39e69164 feat(core): track shell cursor visibility in output events 2026-03-27 22:39:12 -07:00
Keith Guerin 317c13f846 refactor(ui): use constant for interactive shell waiting phrase 2026-03-27 22:39:12 -07:00
Keith Guerin a512438f37 chore: remove obsolete snapshot 2026-03-27 22:39:12 -07:00
Keith Guerin 4829e80596 fix(cli): refine shell focus toast color and text 2026-03-27 22:39:11 -07:00
Christian Gunderman 07ab16dbbe feat(cli): support 'tab to queue' for messages while generating (#24052) 2026-03-28 01:31:11 +00:00
Abhijit Balaji afc1d50c20 feat(core): implement tool-based topic grouping (Chapters) (#23150)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-03-28 01:28:25 +00:00
Gal Zahavi ae123c547c fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox (#24057) 2026-03-28 00:14:35 +00:00
Keith Guerin c2705e8332 fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) 2026-03-28 00:06:07 +00:00
Sam Roberts 9574855435 Re-word intro to Gemini 3 page. (#24069) 2026-03-28 00:01:22 +00:00
53 changed files with 1125 additions and 109 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
<!-- prettier-ignore -->
> [!NOTE]
+7 -6
View File
@@ -86,12 +86,13 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
+1 -6
View File
@@ -1125,12 +1125,7 @@ describe('mergeExcludeTools', () => {
]);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
settings,
'test-session',
argv,
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
-2
View File
@@ -12,8 +12,6 @@ import { vi } from 'vitest';
// or @testing-library/react-native
// The version of waitFor from vitest is still fine to use if you aren't waiting
// for React state updates.
export const skipFlaky = !process.env['RUN_FLAKY_INTEGRATION'];
export async function waitFor(
assertion: () => void | Promise<void>,
{ timeout = 2000, interval = 50 } = {},
+1
View File
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
addMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
+6
View File
@@ -1108,6 +1108,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
isCursorHidden,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
@@ -1177,6 +1178,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingToolCalls,
embeddedShellFocused,
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
isCursorHidden,
});
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
@@ -2326,6 +2328,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
isCursorHidden,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
@@ -2454,6 +2457,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
isCursorHidden,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
@@ -2502,6 +2506,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2593,6 +2598,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
addMessage: vi.fn(),
};
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount();
});
it('queues a message when Tab is pressed during generation', async () => {
props.buffer.setText('A new prompt');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
expect(props.buffer.text).toBe('');
});
unmount();
});
it('shows an error when attempting to queue a slash command', async () => {
props.buffer.setText('/clear');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Slash commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('shows an error when attempting to queue a shell command', async () => {
props.shellModeActive = true;
props.buffer.setText('ls');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Shell commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
popAllMessages?: () => string | undefined;
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setQueueErrorMessage,
streamingState,
popAllMessages,
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive ||
commandSearchActive;
if (
isGenerating &&
isQueueMessageKey &&
!hasTabCompletionInteraction &&
buffer.text.trim().length > 0
) {
const trimmedMessage = buffer.text.trim();
const isSlash = isSlashCommand(trimmedMessage);
if (isSlash || shellModeActive) {
setQueueErrorMessage(
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
);
} else if (onQueueMessage) {
onQueueMessage(buffer.text);
buffer.setText('');
resetCompletionState();
resetReverseSearchCompletionState();
}
resetPlainTabPress();
return true;
}
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
onQueueMessage,
setQueueErrorMessage,
resetReverseSearchCompletionState,
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
@@ -7,7 +7,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
import { waitFor, skipFlaky } from '../../test-utils/async.js';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -318,7 +318,7 @@ describe('getToolGroupBorderAppearance', () => {
});
});
describe.skipIf(skipFlaky)('MainContent', () => {
describe('MainContent', () => {
const defaultMockUiState = {
history: [
{ id: 1, type: 'user', text: 'Hello' },
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
vi.mock('../hooks/useComposerStatus.js', () => ({
useComposerStatus: vi.fn(),
}));
describe('<StatusRow />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const defaultUiState: Partial<UIState> = {
currentTip: undefined,
thought: null,
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
it('renders status and tip correctly when they both fit', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
currentWittyPhrase: 'I am witty',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
expect(output).toContain('I am witty');
expect(output).toContain('Tip: Test Tip');
});
it('renders correctly when interactive shell is waiting', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: true,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState: defaultUiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(INTERACTIVE_SHELL_WAITING_PHRASE);
});
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
});
+26 -13
View File
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
onResize={onStatusResize}
/>
);
@@ -303,8 +313,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
<Text color={theme.ui.active}>
{INTERACTIVE_SHELL_WAITING_PHRASE}
</Text>
</Box>
) : (
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
@@ -70,6 +70,7 @@ export interface UIActions {
handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void;
addMessage: (message: string) => void;
popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
@@ -217,6 +217,7 @@ export interface UIState {
settingsNonce: number;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
isCursorHidden?: boolean;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
@@ -242,7 +242,12 @@ export const useShellCommandProcessor = (
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
dispatch({
type: 'APPEND_SHELL_OUTPUT',
pid,
chunk: event.chunk,
isCursorHidden: event.isCursorHidden,
});
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
@@ -381,6 +386,8 @@ export const useShellCommandProcessor = (
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
isCursorHidden:
event.type === 'data' ? event.isCursorHidden : undefined,
});
return;
}
@@ -396,7 +403,12 @@ export const useShellCommandProcessor = (
}
if (shouldUpdate) {
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
dispatch({
type: 'SET_OUTPUT_TIME',
time: Date.now(),
isCursorHidden:
event.type === 'data' ? event.isCursorHidden : undefined,
});
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
@@ -550,5 +562,6 @@ export const useShellCommandProcessor = (
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
isCursorHidden: state.isCursorHidden,
};
};
+17 -3
View File
@@ -14,6 +14,7 @@ export interface BackgroundShell {
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
isCursorHidden?: boolean;
}
export interface ShellState {
@@ -21,11 +22,12 @@ export interface ShellState {
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
isCursorHidden?: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_OUTPUT_TIME'; time: number; isCursorHidden?: boolean }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
@@ -35,7 +37,12 @@ export type ShellAction =
initialOutput: string | AnsiOutput;
}
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| {
type: 'APPEND_SHELL_OUTPUT';
pid: number;
chunk: string | AnsiOutput;
isCursorHidden?: boolean;
}
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
@@ -54,7 +61,11 @@ export function shellReducer(
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
return {
...state,
lastShellOutputTime: action.time,
isCursorHidden: action.isCursorHidden,
};
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
@@ -103,6 +114,9 @@ export function shellReducer(
newOutput = action.chunk;
}
shell.output = newOutput;
if (action.isCursorHidden !== undefined) {
shell.isCursorHidden = action.isCursorHidden;
}
const nextState = { ...state, lastShellOutputTime: Date.now() };
@@ -371,6 +371,7 @@ export const useGeminiStream = (
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
isCursorHidden,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
@@ -2028,6 +2029,7 @@ export const useGeminiStream = (
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
isCursorHidden,
dismissBackgroundShell,
retryStatus,
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
export const PHRASE_CHANGE_INTERVAL_MS = 10000;
export const WITTY_PHRASE_CHANGE_INTERVAL_MS = 5000;
export const INTERACTIVE_SHELL_WAITING_PHRASE =
'! Shell awaiting input (Tab to focus)';
'Shell awaiting input (Tab to focus)';
/**
* Custom hook to manage cycling through loading phrases.
@@ -106,4 +106,17 @@ describe('useShellInactivityStatus', () => {
});
expect(result.current.shouldShowFocusHint).toBe(false);
});
it('should suppress all inactivity indicators when cursor is hidden', async () => {
const { result } = await renderHook(() =>
useShellInactivityStatus({ ...defaultProps, isCursorHidden: true }),
);
// After 30s, status should still be 'none' and focus hint false
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
});
expect(result.current.inactivityStatus).toBe('none');
expect(result.current.shouldShowFocusHint).toBe(false);
});
});
@@ -21,6 +21,7 @@ interface ShellInactivityStatusProps {
pendingToolCalls: TrackedToolCall[];
embeddedShellFocused: boolean;
isInteractiveShellEnabled: boolean;
isCursorHidden?: boolean;
}
export type InactivityStatus = 'none' | 'action_required' | 'silent_working';
@@ -41,6 +42,7 @@ export const useShellInactivityStatus = ({
pendingToolCalls,
embeddedShellFocused,
isInteractiveShellEnabled,
isCursorHidden,
}: ShellInactivityStatusProps): ShellInactivityStatus => {
const { operationStartTime, isRedirectionActive } = useTurnActivityMonitor(
streamingState,
@@ -49,7 +51,10 @@ export const useShellInactivityStatus = ({
);
const isAwaitingFocus =
!!activePtyId && !embeddedShellFocused && isInteractiveShellEnabled;
!!activePtyId &&
!embeddedShellFocused &&
isInteractiveShellEnabled &&
!isCursorHidden;
// Derive whether output was produced by comparing the last output time to when the operation started.
const hasProducedOutput = lastOutputTime > operationStartTime;
+5
View File
@@ -74,6 +74,7 @@ export enum Command {
// Text Input
SUBMIT = 'input.submit',
QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT, [new KeyBinding('enter')]],
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
[
Command.NEWLINE,
[
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
title: 'Text Input',
commands: [
Command.SUBMIT,
Command.QUEUE_MESSAGE,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Text Input
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.QUEUE_MESSAGE]:
'Queue the current prompt to be processed after the current task finishes.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',
+12
View File
@@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.sortTools = vi.fn();
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
return { ToolRegistry: ToolRegistryMock };
});
@@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => {
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
it('should have independent TopicState across instances', () => {
const config1 = new Config(baseParams);
const config2 = new Config(baseParams);
config1.topicState.setTopic('Topic 1');
config2.topicState.setTopic('Topic 2');
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
});
describe('GemmaModelRouterSettings', () => {
+6
View File
@@ -36,6 +36,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -728,6 +729,7 @@ export class Config implements McpContext, AgentLoopContext {
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
readonly topicState = new TopicState();
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -3354,6 +3356,10 @@ export class Config implements McpContext, AgentLoopContext {
}
};
maybeRegister(UpdateTopicTool, () =>
registry.registerTool(new UpdateTopicTool(this, this.messageBus)),
);
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
);
@@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => {
getSkills: vi.fn().mockReturnValue([]),
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isTrackerEnabled: vi.fn().mockReturnValue(false),
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -113,6 +113,13 @@ decision = "allow"
priority = 70
modes = ["plan"]
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 70
modes = ["plan"]
[[rule]]
toolName = ["ask_user", "save_memory"]
decision = "ask_user"
@@ -55,4 +55,10 @@ priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
decision = "allow"
priority = 50
# Topic grouping tool is innocuous and used for UI organization.
[[rule]]
toolName = "update_topic"
decision = "allow"
priority = 50
@@ -7,13 +7,14 @@ allowOverrides = false
[modes.default]
network = false
readonly = true
approvedTools = []
approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo']
allowOverrides = true
[modes.accepting_edits]
network = false
readonly = false
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo', 'Add-Content', 'Set-Content']
allowOverrides = true
[commands]
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import { loadPoliciesFromToml } from './toml-loader.js';
import { PolicyEngine } from './policy-engine.js';
import { ApprovalMode, PolicyDecision } from './types.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
describe('Topic Tool Policy', () => {
async function loadDefaultPolicies() {
// Path relative to packages/core root
const policiesDir = path.resolve(process.cwd(), 'src/policy/policies');
const getPolicyTier = () => 1; // Default tier
const result = await loadPoliciesFromToml([policiesDir], getPolicyTier);
return result.rules;
}
it('should allow update_topic in DEFAULT mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in PLAN mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.PLAN,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should allow update_topic in YOLO mode', async () => {
const rules = await loadDefaultPolicies();
const engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
const result = await engine.check(
{ name: UPDATE_TOPIC_TOOL_NAME },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
@@ -15,6 +15,8 @@ import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
import { ApprovalMode } from '../policy/types.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { TopicState } from '../tools/topicTool.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
@@ -53,6 +55,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
@@ -73,6 +76,8 @@ describe('PromptProvider', () => {
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getApprovalMode: vi.fn(),
isTrackerEnabled: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
} as unknown as Config;
});
@@ -234,4 +239,67 @@ describe('PromptProvider', () => {
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
});
});
describe('Topic & Update Narration', () => {
beforeEach(() => {
mockConfig.topicState.reset();
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]),
getAllTools: vi.fn().mockReturnValue([
new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic',
}),
]),
});
vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true);
vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true);
});
it('should include active topic context when narration is enabled', () => {
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('[Active Topic: Active Chapter]');
});
it('should NOT include active topic context when narration is disabled', () => {
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
mockConfig.topicState.setTopic('Active Chapter');
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
});
it('should filter out update_topic tool when narration is disabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
false,
);
// Simulate registry behavior where it filters out update_topic
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue(
[],
);
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([]);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME);
});
it('should NOT filter out update_topic tool when narration is enabled', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain(`<tool>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
});
});
});
+13 -2
View File
@@ -57,6 +57,7 @@ export class PromptProvider {
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = context.config.getApprovedPlanPath();
const desiredModel = resolveModel(
@@ -71,7 +72,6 @@ export class PromptProvider {
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
@@ -232,7 +232,18 @@ export class PromptProvider {
);
// Sanitize erratic newlines from composition
const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
// Context Reinjection (Active Topic)
if (context.config.isTopicUpdateNarrationEnabled()) {
const activeTopic = context.config.topicState.getTopic();
if (activeTopic) {
const sanitizedTopic = activeTopic
.replace(/\n/g, ' ')
.replace(/\]/g, '');
sanitizedPrompt += `\n\n[Active Topic: ${sanitizedTopic}]`;
}
}
// Write back to file if requested
this.maybeWriteSystemMd(
+18 -40
View File
@@ -10,6 +10,9 @@ import {
EDIT_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
@@ -239,7 +242,9 @@ Use the following guidelines to optimize your search and read patterns.
? mandateTopicUpdateModel()
: mandateExplainBeforeActing()
}
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateContinueWork(options.interactive)}
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(
options.hasSkills,
)}${mandateContinueWork(options.interactive)}
`.trim();
}
@@ -361,7 +366,7 @@ export function renderOperationalGuidelines(
- **Role:** A senior software engineer and collaborative peer programmer.
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${
options.topicUpdateNarration
? 'per-tool explanations.'
? 'unnecessary per-tool explanations.'
: 'mechanical tool-use narration (e.g., "I will now call...").'
}
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
@@ -614,46 +619,19 @@ function mandateConfirm(interactive: boolean): string {
function mandateTopicUpdateModel(): string {
return `
- **Protocol: Topic Model**
You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format.
## Topic Updates
As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following:
- **1. Topic Initialization & Persistence:**
- **The Trigger:** You MUST issue a \`Topic: <Phase> : <Brief Summary>\` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation).
- **The Format:** Use exactly \`Topic: <Phase> : <Brief Summary>\` (e.g., \`Topic: <Research> : Researching Agent Skills in the repo\`).
- **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase.
- **Start of Task:** Your very first tool execution must be preceded by a Topic header.
- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done.
- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter.
- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn.
- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z".
- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour.
- **Examples:**
- \`update_topic(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")\`
- \`update_topic(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")\`
- **2. Tool Execution Protocol (Zero-Noise):**
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
- **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools.
- Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent.
- **3. Thinking Protocol:**
- Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task.
- Without reasoning and tracking in thought blocks, you may lose context.
- Always use the required syntax for thought blocks to ensure they remain hidden from the user interface.
- **4. Completion:**
- Only when the entire task is finalized do you provide a **Final Summary**.
**IMPORTANT: Topic Headers vs. Thoughts**
The \`Topic: <Phase> : <Brief Summary>\` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI.
**Correct State Log Example:**
\`\`\`
Topic: <Research> : Researching Agent Skills in the repo
<tool_call 1>
<tool_call 2>
<tool_call 3>
Topic: <Implementation> : Implementing the skill-creator logic
<tool_call 1>
<tool_call 2>
The task is complete. [Final Summary]
\`\`\`
- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`;
`;
}
function mandateExplainBeforeActing(): string {
@@ -187,7 +187,7 @@ export class LinuxSandboxManager implements SandboxManager {
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
@@ -78,7 +78,7 @@ export class MacOsSandboxManager implements SandboxManager {
const workspaceWrite = !isReadonlyMode || isApproved;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.args);
@@ -58,6 +58,13 @@ public class GeminiSandbox {
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_NET_RATE_CONTROL_INFORMATION {
public ulong MaxBandwidth;
public uint ControlFlags;
public byte DscpTag;
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
@@ -70,6 +77,9 @@ public class GeminiSandbox {
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
@@ -143,6 +153,8 @@ public class GeminiSandbox {
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
static int Main(string[] args) {
if (args.Length < 3) {
@@ -182,14 +194,14 @@ public class GeminiSandbox {
IntPtr lowIntegritySid = IntPtr.Zero;
try {
// 1. Create Restricted Token
if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) {
// 1. Duplicate Primary Token
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) {
Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
// Flags: 0x1 (DISABLE_MAX_PRIVILEGE)
if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
// Create a restricted token to strip administrative privileges
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
@@ -223,6 +235,18 @@ public class GeminiSandbox {
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
Marshal.FreeHGlobal(lpJobLimits);
if (!networkAccess) {
JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION();
netLimits.MaxBandwidth = 1;
netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH
netLimits.DscpTag = 0;
IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits));
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits));
Marshal.FreeHGlobal(lpNetLimits);
}
// 4. Handle Internal Commands or External Process
if (command == "__read") {
if (argIndex + 1 >= args.Length) {
@@ -158,7 +158,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
persistentPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
});
@@ -227,13 +227,13 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(testCwd),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
@@ -273,7 +273,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(extraWritePath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
@@ -308,7 +308,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).not.toContainEqual([
uncPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -343,12 +343,12 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
longPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
devicePath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -236,6 +236,10 @@ export class WindowsSandboxManager implements SandboxManager {
false,
};
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const networkAccess = defaultNetwork || mergedAdditional.network;
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the workspace.
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
@@ -251,7 +255,7 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(this.options.workspace);
}
// Grant "Low Mandatory Level" read access to allowedPaths.
// Grant "Low Mandatory Level" read/write access to allowedPaths.
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
for (const allowedPath of allowedPaths) {
await this.grantLowIntegrityAccess(allowedPath);
@@ -342,10 +346,6 @@ export class WindowsSandboxManager implements SandboxManager {
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
const program = this.helperPath;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
const args = [
networkAccess ? '1' : '0',
req.cwd,
@@ -395,7 +395,11 @@ export class WindowsSandboxManager implements SandboxManager {
}
try {
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
await spawnAsync('icacls', [
resolvedPath,
'/setintegritylevel',
'(OI)(CI)Low',
]);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
@@ -74,6 +74,7 @@ import {
type AnyDeclarativeTool,
type AnyToolInvocation,
} from '../tools/tools.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import {
CoreToolCallStatus,
ROOT_SCHEDULER_ID,
@@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => {
]),
);
});
it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => {
const topicReq: ToolCallRequestInfo = {
callId: 'call-topic',
name: UPDATE_TOPIC_TOOL_NAME,
args: { title: 'New Chapter' },
prompt_id: 'p1',
isClientInitiated: false,
};
const otherReq: ToolCallRequestInfo = {
callId: 'call-other',
name: 'test-tool',
args: {},
prompt_id: 'p1',
isClientInitiated: false,
};
// Mock tool registry to return a tool for update_topic
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
if (name === UPDATE_TOPIC_TOOL_NAME) {
return {
name: UPDATE_TOPIC_TOOL_NAME,
build: vi.fn().mockReturnValue({}),
} as unknown as AnyDeclarativeTool;
}
return mockTool;
});
// Schedule in reverse order (other first, topic second)
await scheduler.schedule([otherReq, topicReq], signal);
// Verify they were enqueued in the correct sorted order (topic first)
const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls;
const lastCall = enqueueCalls[enqueueCalls.length - 1][0];
expect(lastCall[0].request.callId).toBe('call-topic');
expect(lastCall[1].request.callId).toBe('call-other');
});
});
describe('Phase 2: Queue Management', () => {
+9 -1
View File
@@ -26,6 +26,7 @@ import {
type ScheduledToolCall,
} from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
@@ -302,9 +303,16 @@ export class Scheduler {
this.state.clearBatch();
const currentApprovalMode = this.config.getApprovalMode();
// Sort requests to ensure Topic changes happen before actions in the same batch.
const sortedRequests = [...requests].sort((a, b) => {
if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1;
if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1;
return 0;
});
try {
const toolRegistry = this.context.toolRegistry;
const newCalls: ToolCall[] = requests.map((request) => {
const newCalls: ToolCall[] = sortedRequests.map((request) => {
const enrichedRequest: ToolCallRequestInfo = {
...request,
schedulerId: this.schedulerId,
@@ -36,6 +36,7 @@ export type ExecutionOutputEvent =
| {
type: 'data';
chunk: string | AnsiOutput;
isCursorHidden?: boolean;
}
| {
type: 'binary_detected';
@@ -385,5 +385,14 @@ describe('SandboxManager', () => {
expect(manager).toBeInstanceOf(expected);
},
);
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
});
});
@@ -299,10 +299,12 @@ describe('ShellExecutionService', () => {
expect(result.output.trim()).toBe('file1.txt');
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: createExpectedAnsiOutput('file1.txt'),
});
expect(onOutputEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'data',
chunk: createExpectedAnsiOutput('file1.txt'),
}),
);
});
it('should strip ANSI color codes from output', async () => {
@@ -46,6 +46,19 @@ import {
} from './executionLifecycleService.js';
const { Terminal } = pkg;
/**
* Internal interfaces to access non-public xterm properties.
*/
interface XTermCore {
coreService: {
isCursorHidden: boolean;
};
}
interface XTermInternal {
_core?: XTermCore;
}
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
/**
@@ -952,9 +965,14 @@ export class ShellExecutionService {
if (output !== finalOutput) {
output = finalOutput;
const isCursorHidden =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(headlessTerminal as unknown as XTermInternal)._core?.coreService
?.isCursorHidden;
const event: ShellOutputEvent = {
type: 'data',
chunk: finalOutput,
isCursorHidden,
};
onOutputEvent(event);
ExecutionLifecycleService.emitEvent(ptyPid, event);
@@ -1303,7 +1321,15 @@ export class ShellExecutionService {
startLine,
endLine,
);
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
const isCursorHidden =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(activePty.headlessTerminal as unknown as XTermInternal)._core
?.coreService?.isCursorHidden;
const event: ShellOutputEvent = {
type: 'data',
chunk: bufferData,
isCursorHidden,
};
ExecutionLifecycleService.emitEvent(pid, event);
}
}
@@ -125,3 +125,9 @@ export const PLAN_MODE_PARAM_REASON = 'reason';
// -- sandbox --
export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
// -- update_topic --
export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
export const TOPIC_PARAM_TITLE = 'title';
export const TOPIC_PARAM_SUMMARY = 'summary';
export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
@@ -17,6 +17,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from './dynamic-declaration-helpers.js';
// Re-export names for compatibility
@@ -38,6 +39,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -91,6 +93,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
// Re-export sets for compatibility
@@ -221,6 +226,13 @@ export const ENTER_PLAN_MODE_DEFINITION: ToolDefinition = {
overrides: (modelId) => getToolSet(modelId).enter_plan_mode,
};
export const UPDATE_TOPIC_DEFINITION: ToolDefinition = {
get base() {
return getUpdateTopicDeclaration();
},
overrides: (modelId) => getToolSet(modelId).update_topic,
};
// ============================================================================
// DYNAMIC TOOL DEFINITIONS (LEGACY EXPORTS)
// ============================================================================
@@ -24,6 +24,10 @@ import {
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './base-declarations.js';
/**
@@ -204,3 +208,34 @@ export function getActivateSkillDeclaration(
parametersJsonSchema: zodToJsonSchema(schema),
};
}
/**
* Returns the FunctionDeclaration for updating the topic context.
*/
export function getUpdateTopicDeclaration(): FunctionDeclaration {
return {
name: UPDATE_TOPIC_TOOL_NAME,
description:
'Manages your narrative flow. Include `title` and `summary` only when starting a new Chapter (logical phase) or shifting strategic intent.',
parametersJsonSchema: {
type: 'object',
properties: {
[TOPIC_PARAM_TITLE]: {
type: 'string',
description: 'The title of the new topic or chapter.',
},
[TOPIC_PARAM_SUMMARY]: {
type: 'string',
description:
'(OPTIONAL) A detailed summary (5-10 sentences) covering both the work completed in the previous topic and the strategic intent of the new topic. This is required when transitioning between topics to maintain continuity.',
},
[TOPIC_PARAM_STRATEGIC_INTENT]: {
type: 'string',
description:
'A mandatory one-sentence statement of your immediate intent.',
},
},
required: [TOPIC_PARAM_STRATEGIC_INTENT],
},
};
}
@@ -78,6 +78,7 @@ import {
getShellDeclaration,
getExitPlanModeDeclaration,
getActivateSkillDeclaration,
getUpdateTopicDeclaration,
} from '../dynamic-declaration-helpers.js';
import {
DEFAULT_MAX_LINES_TEXT_FILE,
@@ -724,4 +725,5 @@ The agent did not use the todo list because this task could be completed by a ti
exit_plan_mode: () => getExitPlanModeDeclaration(),
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
update_topic: getUpdateTopicDeclaration(),
};
@@ -50,4 +50,5 @@ export interface CoreToolSet {
enter_plan_mode: FunctionDeclaration;
exit_plan_mode: () => FunctionDeclaration;
activate_skill: (skillNames: string[]) => FunctionDeclaration;
update_topic?: FunctionDeclaration;
}
+10
View File
@@ -75,6 +75,10 @@ import {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
export {
@@ -95,6 +99,7 @@ export {
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
// Shared parameter names
PARAM_FILE_PATH,
PARAM_DIR_PATH,
@@ -148,6 +153,9 @@ export {
PLAN_MODE_PARAM_REASON,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SKILL_PARAM_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
};
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
@@ -253,6 +261,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
GET_INTERNAL_DOCS_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
] as const;
/**
@@ -269,6 +278,7 @@ export const PLAN_MODE_TOOLS = [
ASK_USER_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
'codebase_investigator',
'cli_help',
] as const;
+34 -1
View File
@@ -19,7 +19,10 @@ import { Config, type ConfigParameters } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
import {
DISCOVERED_TOOL_PREFIX,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import {
mcpToTool,
@@ -800,6 +803,36 @@ describe('ToolRegistry', () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool');
});
it('should exclude topic tool when narration is disabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(false);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).not.toContain(
UPDATE_TOPIC_TOOL_NAME,
);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBeUndefined();
});
it('should NOT exclude topic tool when narration is enabled in config', () => {
const topicTool = new MockTool({
name: UPDATE_TOPIC_TOOL_NAME,
displayName: 'Topic Tool',
});
toolRegistry.registerTool(topicTool);
vi.spyOn(config, 'isTopicUpdateNarrationEnabled').mockReturnValue(true);
mockConfigGetExcludedTools.mockReturnValue(new Set());
expect(toolRegistry.getAllToolNames()).toContain(UPDATE_TOPIC_TOOL_NAME);
expect(toolRegistry.getTool(UPDATE_TOPIC_TOOL_NAME)).toBe(topicTool);
});
});
describe('DiscoveredToolInvocation', () => {
+7
View File
@@ -30,6 +30,7 @@ import {
getToolAliases,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
UPDATE_TOPIC_TOOL_NAME,
} from './tool-names.js';
type ToolParams = Record<string, unknown>;
@@ -576,6 +577,12 @@ export class ToolRegistry {
),
) ?? new Set([]);
if (tool.name === UPDATE_TOPIC_TOOL_NAME) {
if (!this.config.isTopicUpdateNarrationEnabled()) {
return false;
}
}
const normalizedClassName = tool.constructor.name.replace(/^_+/, '');
const possibleNames = [tool.name, normalizedClassName];
if (tool instanceof DiscoveredMCPTool) {
+133
View File
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TopicState, UpdateTopicTool } from './topicTool.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../policy/policy-engine.js';
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/base-declarations.js';
import type { Config } from '../config/config.js';
describe('TopicState', () => {
let state: TopicState;
beforeEach(() => {
state = new TopicState();
});
it('should store and retrieve topic title and intent', () => {
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
const success = state.setTopic('Test Topic', 'Test Intent');
expect(success).toBe(true);
expect(state.getTopic()).toBe('Test Topic');
expect(state.getIntent()).toBe('Test Intent');
});
it('should sanitize newlines and carriage returns', () => {
state.setTopic('Topic\nWith\r\nLines', 'Intent\nWith\r\nLines');
expect(state.getTopic()).toBe('Topic With Lines');
expect(state.getIntent()).toBe('Intent With Lines');
});
it('should trim whitespace', () => {
state.setTopic(' Spaced Topic ', ' Spaced Intent ');
expect(state.getTopic()).toBe('Spaced Topic');
expect(state.getIntent()).toBe('Spaced Intent');
});
it('should reject empty or whitespace-only inputs', () => {
expect(state.setTopic('', '')).toBe(false);
});
it('should reset topic and intent', () => {
state.setTopic('Test Topic', 'Test Intent');
state.reset();
expect(state.getTopic()).toBeUndefined();
expect(state.getIntent()).toBeUndefined();
});
});
describe('UpdateTopicTool', () => {
let tool: UpdateTopicTool;
let mockMessageBus: MessageBus;
let mockConfig: Config;
beforeEach(() => {
mockMessageBus = new MessageBus(vi.mocked({} as PolicyEngine));
// Mock enough of Config to satisfy the tool
mockConfig = {
topicState: new TopicState(),
} as unknown as Config;
tool = new UpdateTopicTool(mockConfig, mockMessageBus);
});
it('should have correct name and display name', () => {
expect(tool.name).toBe(UPDATE_TOPIC_TOOL_NAME);
expect(tool.displayName).toBe('Update Topic Context');
});
it('should update TopicState and include strategic intent on execute', async () => {
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_SUMMARY]: 'The goal is to implement X. Previously we did Y.',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Initial Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Current topic: "New Chapter"');
expect(result.llmContent).toContain(
'Topic summary: The goal is to implement X. Previously we did Y.',
);
expect(result.llmContent).toContain('Strategic Intent: Initial Move');
expect(mockConfig.topicState.getTopic()).toBe('New Chapter');
expect(mockConfig.topicState.getIntent()).toBe('Initial Move');
expect(result.returnDisplay).toContain('## 📂 Topic: **New Chapter**');
expect(result.returnDisplay).toContain('**Summary:**');
expect(result.returnDisplay).toContain(
'> [!STRATEGY]\n> **Intent:** Initial Move',
);
});
it('should render only intent for tactical updates (same topic)', async () => {
mockConfig.topicState.setTopic('New Chapter');
const invocation = tool.build({
[TOPIC_PARAM_TITLE]: 'New Chapter',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Subsequent Move',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).not.toContain('## 📂 Topic:');
expect(result.returnDisplay).toBe(
'> [!STRATEGY]\n> **Intent:** Subsequent Move',
);
expect(result.llmContent).toBe('Strategic Intent: Subsequent Move');
});
it('should return error if strategic_intent is missing', async () => {
try {
tool.build({
[TOPIC_PARAM_TITLE]: 'Title',
});
expect.fail('Should have thrown validation error');
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toContain(
"must have required property 'strategic_intent'",
);
} else {
expect.fail('Expected Error instance');
}
}
expect(mockConfig.topicState.getTopic()).toBeUndefined();
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
UPDATE_TOPIC_TOOL_NAME,
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
} from './definitions/coreTools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolResult,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
import type { Config } from '../config/config.js';
/**
* Manages the current active topic title and tactical intent for a session.
* Hosted within the Config instance for session-scoping.
*/
export class TopicState {
private activeTopicTitle?: string;
private activeIntent?: string;
/**
* Sanitizes and sets the topic title and/or intent.
* @returns true if the input was valid and set, false otherwise.
*/
setTopic(title?: string, intent?: string): boolean {
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
if (!sanitizedTitle && !sanitizedIntent) return false;
if (sanitizedTitle) {
this.activeTopicTitle = sanitizedTitle;
}
if (sanitizedIntent) {
this.activeIntent = sanitizedIntent;
}
return true;
}
getTopic(): string | undefined {
return this.activeTopicTitle;
}
getIntent(): string | undefined {
return this.activeIntent;
}
reset(): void {
this.activeTopicTitle = undefined;
this.activeIntent = undefined;
}
}
interface UpdateTopicParams {
[TOPIC_PARAM_TITLE]?: string;
[TOPIC_PARAM_SUMMARY]?: string;
[TOPIC_PARAM_STRATEGIC_INTENT]?: string;
}
class UpdateTopicInvocation extends BaseToolInvocation<
UpdateTopicParams,
ToolResult
> {
constructor(
params: UpdateTopicParams,
messageBus: MessageBus,
toolName: string,
private readonly config: Config,
) {
super(params, messageBus, toolName);
}
getDescription(): string {
const title = this.params[TOPIC_PARAM_TITLE];
const intent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
if (title) {
return `Update topic to: "${title}"`;
}
return `Update tactical intent: "${intent || '...'}"`;
}
async execute(): Promise<ToolResult> {
const title = this.params[TOPIC_PARAM_TITLE];
const summary = this.params[TOPIC_PARAM_SUMMARY];
const strategicIntent = this.params[TOPIC_PARAM_STRATEGIC_INTENT];
const activeTopic = this.config.topicState.getTopic();
const isNewTopic = !!(
title &&
title.trim() !== '' &&
title.trim() !== activeTopic
);
this.config.topicState.setTopic(title, strategicIntent);
const currentTopic = this.config.topicState.getTopic() || '...';
const currentIntent =
strategicIntent || this.config.topicState.getIntent() || '...';
debugLogger.log(
`[TopicTool] Update: Topic="${currentTopic}", Intent="${currentIntent}", isNew=${isNewTopic}`,
);
let llmContent = '';
let returnDisplay = '';
if (isNewTopic) {
// Handle New Topic Header & Summary
llmContent = `Current topic: "${currentTopic}"\nTopic summary: ${summary || '...'}`;
returnDisplay = `## 📂 Topic: **${currentTopic}**\n\n**Summary:**\n${summary || '...'}`;
if (strategicIntent && strategicIntent.trim()) {
llmContent += `\n\nStrategic Intent: ${strategicIntent.trim()}`;
returnDisplay += `\n\n> [!STRATEGY]\n> **Intent:** ${strategicIntent.trim()}`;
}
} else {
// Tactical update only
llmContent = `Strategic Intent: ${currentIntent}`;
returnDisplay = `> [!STRATEGY]\n> **Intent:** ${currentIntent}`;
}
return {
llmContent,
returnDisplay,
};
}
}
/**
* Tool to update semantic topic context and tactical intent for UI grouping and model focus.
*/
export class UpdateTopicTool extends BaseDeclarativeTool<
UpdateTopicParams,
ToolResult
> {
constructor(
private readonly config: Config,
messageBus: MessageBus,
) {
const declaration = getUpdateTopicDeclaration();
super(
UPDATE_TOPIC_TOOL_NAME,
'Update Topic Context',
declaration.description ?? '',
Kind.Think,
declaration.parametersJsonSchema,
messageBus,
);
}
protected createInvocation(
params: UpdateTopicParams,
messageBus: MessageBus,
): UpdateTopicInvocation {
return new UpdateTopicInvocation(
params,
messageBus,
this.name,
this.config,
);
}
}
+16 -2
View File
@@ -408,7 +408,9 @@ function hasPromptCommandTransform(root: Node): boolean {
return false;
}
function parseBashCommandDetails(command: string): CommandParseResult | null {
export function parseBashCommandDetails(
command: string,
): CommandParseResult | null {
if (treeSitterInitializationError) {
debugLogger.debug(
'Bash parser not initialized:',
@@ -557,7 +559,19 @@ export function parseCommandDetails(
const configuration = getShellConfiguration();
if (configuration.shell === 'powershell') {
return parsePowerShellCommandDetails(command, configuration.executable);
const result = parsePowerShellCommandDetails(
command,
configuration.executable,
);
if (!result || result.hasError) {
// Fallback to bash parser which is usually good enough for simple commands
// and doesn't rely on the host PowerShell environment restrictions (e.g., ConstrainedLanguage)
const bashResult = parseBashCommandDetails(command);
if (bashResult && !bashResult.hasError) {
return bashResult;
}
}
return result;
}
if (configuration.shell === 'bash') {