mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-14 02:51:43 +00:00
feat(cli): add GitHub Copilot CLI agent support via ACP (#1245)
* feat(cli): add GitHub Copilot CLI agent support via ACP Wrap `copilot --acp --stdio` for remote sessions and spawn the native TUI locally, with full hub/web integration for spawn, resume, and permissions. Fixes tiann/hapi#362 Co-Authored-By: HAPI <noreply@hapi.run> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(copilot): agent modes, models, slash/file UX, local session sync Add Interactive/Plan/Autopilot (fleet is slash-only), subscription-aware model discovery, web StatusBar/permission UX, @ file mentions, and fix local Safe Yolo plus session-id locator for handoff/resume. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: re-trigger Codex PR review after auth outage Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retry Codex PR review Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): preserve agent mode on resume and apply via ACP set_mode Resume was dropping copilotAgentMode so Plan/Autopilot reset to interactive. Also switch local/remote mode application to --mode / session set_mode instead of slash prompts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): wake remote loop when agent mode changes Empty isolated queue tick lets setMode apply without inventing a user prompt. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): confirm mode changes before persisting Await Copilot mode changes and expose discovered models so session state reflects backend acceptance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): guard mode discovery and slash updates Keep model probes within runner roots and preserve active sessions when mode switching is unavailable or rejected. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): preserve resume and auto semantics Deduplicate Copilot resume rows, apply Auto explicitly, and fail closed on denied permissions. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): close permission and model discovery gaps Keep write-capable commands pending in read-only mode, extend model probe RPCs, and preserve explicit model validation before session creation. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): persist runtime model and agent mode Fallback to ACP model options when direct model switching is unavailable and retain Copilot agent mode across hub restarts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): normalize composer auto selection Use the null session sentinel for Copilot Auto so the composer selects and resets default models consistently. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(copilot): reject local permission mode changes * style(copilot): remove trailing blank line * fix(copilot): secure local config handoffs * fix(copilot): reject local agent mode slashes * fix(copilot): reject mode changes during turns * fix(copilot): consume rejected slash updates * fix(copilot): preserve thinking across slash handling * fix(copilot): stabilize async config changes * fix(copilot): roll back rejected startup model * fix(copilot): preserve cancellation and file mentions * fix(copilot): hide local permission controls * fix(deps): support clean workspace installs * test(copilot): account for spawn mode argument * fix(copilot): attribute usage to active model --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
HAPI
parent
b67f4e56e5
commit
f10fbc7496
@@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { MessageBuffer, type BufferedMessage } from './messageBuffer';
|
||||
import { useSwitchControls } from './useSwitchControls';
|
||||
|
||||
interface CopilotDisplayProps {
|
||||
messageBuffer: MessageBuffer;
|
||||
logPath?: string;
|
||||
onExit?: () => void;
|
||||
onSwitchToLocal?: () => void;
|
||||
}
|
||||
|
||||
function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE' | 'AGENT_MODE'): string | null {
|
||||
const prefix = `[${tag}:`;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.type !== 'system') {
|
||||
continue;
|
||||
}
|
||||
if (!message.content.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const match = message.content.match(/\[\w+:(.+?)\]/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const CopilotDisplay: React.FC<CopilotDisplayProps> = ({
|
||||
messageBuffer,
|
||||
logPath,
|
||||
onExit,
|
||||
onSwitchToLocal
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<BufferedMessage[]>([]);
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
const [permissionMode, setPermissionMode] = useState<string | null>(null);
|
||||
const [agentMode, setAgentMode] = useState<string | null>(null);
|
||||
const { confirmationMode, actionInProgress } = useSwitchControls({
|
||||
onExit,
|
||||
onSwitch: onSwitchToLocal
|
||||
});
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout.columns || 80;
|
||||
const terminalHeight = stdout.rows || 24;
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(messageBuffer.getMessages());
|
||||
|
||||
const unsubscribe = messageBuffer.onUpdate((newMessages) => {
|
||||
setMessages(newMessages);
|
||||
const nextModel = extractTag(newMessages, 'MODEL');
|
||||
if (nextModel) {
|
||||
setModel(nextModel);
|
||||
}
|
||||
const nextMode = extractTag(newMessages, 'MODE');
|
||||
if (nextMode) {
|
||||
setPermissionMode(nextMode);
|
||||
}
|
||||
const nextAgentMode = extractTag(newMessages, 'AGENT_MODE');
|
||||
if (nextAgentMode) {
|
||||
setAgentMode(nextAgentMode);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [messageBuffer]);
|
||||
|
||||
const getMessageColor = (type: BufferedMessage['type']): string => {
|
||||
switch (type) {
|
||||
case 'user': return 'magenta';
|
||||
case 'assistant': return 'cyan';
|
||||
case 'system': return 'blue';
|
||||
case 'tool': return 'yellow';
|
||||
case 'result': return 'green';
|
||||
case 'status': return 'gray';
|
||||
default: return 'white';
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessage = (msg: BufferedMessage): string => {
|
||||
const lines = msg.content.split('\n');
|
||||
const maxLineLength = Math.max(1, terminalWidth - 10);
|
||||
return lines.map(line => {
|
||||
if (line.length <= maxLineLength) return line;
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < line.length; i += maxLineLength) {
|
||||
chunks.push(line.slice(i, i + maxLineLength));
|
||||
}
|
||||
return chunks.join('\n');
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const visibleMessages = messages.filter((msg) => {
|
||||
if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) {
|
||||
return false;
|
||||
}
|
||||
if (msg.type === 'system' && msg.content.startsWith('[MODE:')) {
|
||||
return false;
|
||||
}
|
||||
if (msg.type === 'system' && msg.content.startsWith('[AGENT_MODE:')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth} height={terminalHeight}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalWidth}
|
||||
height={terminalHeight - 4}
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="gray" bold>Copilot Agent Messages</Text>
|
||||
<Text color="gray" dimColor>{'-'.repeat(Math.min(terminalWidth - 4, 60))}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" height={terminalHeight - 10} overflow="hidden">
|
||||
{visibleMessages.length === 0 ? (
|
||||
<Text color="gray" dimColor>Waiting for messages...</Text>
|
||||
) : (
|
||||
visibleMessages
|
||||
.slice(-Math.max(1, terminalHeight - 10))
|
||||
.map((msg) => (
|
||||
<Box key={msg.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={getMessageColor(msg.type)} dimColor>
|
||||
{formatMessage(msg)}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
actionInProgress ? 'gray' :
|
||||
confirmationMode === 'exit' ? 'red' :
|
||||
confirmationMode === 'switch' ? 'yellow' :
|
||||
'green'
|
||||
}
|
||||
paddingX={2}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
{actionInProgress === 'exiting' ? (
|
||||
<Text color="gray" bold>
|
||||
Exiting agent...
|
||||
</Text>
|
||||
) : actionInProgress === 'switching' ? (
|
||||
<Text color="gray" bold>
|
||||
Switching to local mode...
|
||||
</Text>
|
||||
) : confirmationMode === 'exit' ? (
|
||||
<Text color="red" bold>
|
||||
Press Ctrl-C again to exit the agent
|
||||
</Text>
|
||||
) : confirmationMode === 'switch' ? (
|
||||
<Text color="yellow" bold>
|
||||
Press space again to switch to local mode
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="green" bold>
|
||||
Copilot running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'}
|
||||
</Text>
|
||||
)}
|
||||
{(model || permissionMode || agentMode) && (
|
||||
<Text color="gray" dimColor>
|
||||
{model ? `Model: ${model}` : 'Model: auto'}
|
||||
{agentMode ? ` | Mode: ${agentMode}` : ''}
|
||||
{permissionMode ? ` | Permission: ${permissionMode}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{process.env.DEBUG && logPath && (
|
||||
<Text color="gray" dimColor>
|
||||
Debug logs: {logPath}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user