Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 62 additions & 38 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/coder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
"prepublishOnly": "bun run clean && bun run build"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "^0.54.2",
"@mariozechner/pi-tui": "^0.55.1",
"@mariozechner/pi-coding-agent": "^0.57.1",
"@mariozechner/pi-tui": "^0.57.1",
"@sinclair/typebox": "^0.34.48"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.54.2",
"@mariozechner/pi-tui": "^0.55.1",
"@mariozechner/pi-coding-agent": "^0.57.1",
"@mariozechner/pi-tui": "^0.57.1",
"@sinclair/typebox": "^0.34.48",
"@types/bun": "^1.3.9",
"bun-types": "^1.3.9",
Expand Down
69 changes: 19 additions & 50 deletions packages/coder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { AgentManagerOverlay } from './overlay.ts';
import { ChainEditorOverlay, type ChainResult } from './chain-preview.ts';
import { HubOverlay } from './hub-overlay.ts';
import { OutputViewerOverlay, type StoredResult } from './output-viewer.ts';
import { setNativeRemoteExtensionContext } from './native-remote-ui-context.ts';
import { handleRemoteUiRequest } from './remote-ui-handler.ts';
import type {
HubAction,
HubResponse,
Expand Down Expand Up @@ -280,6 +282,7 @@ export function agentuityCoderHub(pi: ExtensionAPI) {
// to an existing sandbox session. The full UI is set up (tools, commands, /hub)
// but user input is relayed to the remote sandbox instead of the local Pi agent.
const remoteSessionId = process.env[REMOTE_SESSION_ENV] || null;
const isNativeRemote = !!process.env[NATIVE_REMOTE_ENV];
if (remoteSessionId) {
log(`Remote mode: will connect as controller to session ${remoteSessionId}`);
}
Expand Down Expand Up @@ -573,7 +576,13 @@ export function agentuityCoderHub(pi: ExtensionAPI) {
// Server sends JSON Schema; TypeBox schemas are JSON Schema at runtime
parameters: toolDef.parameters as TSchema,
...(toolDef.promptSnippet ? { promptSnippet: toolDef.promptSnippet } : {}),
...(toolDef.promptGuidelines ? { promptGuidelines: toolDef.promptGuidelines } : {}),
...(toolDef.promptGuidelines
? {
promptGuidelines: Array.isArray(toolDef.promptGuidelines)
? toolDef.promptGuidelines
: [toolDef.promptGuidelines],
}
: {}),
async execute(
toolCallId: string,
params: unknown,
Expand Down Expand Up @@ -1347,8 +1356,11 @@ export function agentuityCoderHub(pi: ExtensionAPI) {

// session_start: establish WebSocket connection to Hub + set up footer
onEvent('session_start', async (event: unknown, ctx: ExtensionContext) => {
await ensureConnected();
footerCtx = ctx;
if (isNativeRemote && remoteSessionId) {
setNativeRemoteExtensionContext(ctx);
}
await ensureConnected();
if (ctx.hasUI) {
ctx.ui.setStatus('hub_connection', getHubUiStatus());
}
Expand Down Expand Up @@ -1424,8 +1436,6 @@ export function agentuityCoderHub(pi: ExtensionAPI) {
// via Agent.emit(). Extension only provides Hub UI (footer, /hub, commands).
// No pi.sendMessage() rendering, no setupRemoteMode() event handlers.
// 2. Legacy remote: Extension handles all rendering via pi.sendMessage({ customType }).
const isNativeRemote = !!process.env[NATIVE_REMOTE_ENV];

if (remoteSessionId) {
let remoteSession: RemoteSession | null = null;

Expand Down Expand Up @@ -1481,52 +1491,8 @@ export function agentuityCoderHub(pi: ExtensionAPI) {
});

remoteSession.setUiHandler(async (request) => {
if (!footerCtx?.hasUI) return null;
const ui = footerCtx.ui;
switch (request.method) {
case 'select': {
const options =
(request.params.options as Array<{ label: string; value: string }>) ??
[];
const title = (request.params.title as string) ?? 'Select';
const result = await ui.select(
title,
options.map((o) => o.label)
);
if (result === null || result === undefined) return null;
const idx = typeof result === 'number' ? result : Number(result);
return options[idx]?.value ?? null;
}
case 'confirm':
return await ui.confirm(
(request.params.message as string) ?? 'Confirm?',
(request.params.message as string) ?? 'Confirm?'
);
case 'input':
return await ui.input(
(request.params.prompt as string) ?? 'Input:',
(request.params.placeholder as string) ?? ''
);
case 'editor':
return await ui.editor(
(request.params.content as string) ?? '',
(request.params.language as string) ?? 'text'
);
case 'notify':
ui.notify((request.params.message as string) ?? '');
return undefined;
case 'setStatus':
ui.setStatus(
(request.params.key as string) ?? 'remote',
(request.params.text as string) ?? ''
);
return undefined;
case 'setTitle':
ui.setTitle((request.params.title as string) ?? '');
return undefined;
default:
return null;
}
if (!footerCtx) return null;
return await handleRemoteUiRequest(footerCtx, request);
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
Expand All @@ -1549,6 +1515,9 @@ export function agentuityCoderHub(pi: ExtensionAPI) {
'session_shutdown',
async (_event: unknown, _ctx: ExtensionContext) => {
log('Shutting down — closing Hub connection');
if (isNativeRemote && remoteSessionId) {
setNativeRemoteExtensionContext(null);
}
try {
client.close();
} catch {
Expand Down
41 changes: 41 additions & 0 deletions packages/coder/src/native-remote-ui-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';

type ContextWaiter = (ctx: ExtensionContext | null) => void;

let nativeRemoteExtensionContext: ExtensionContext | null = null;
const waiters = new Set<ContextWaiter>();

export function setNativeRemoteExtensionContext(ctx: ExtensionContext | null): void {
nativeRemoteExtensionContext = ctx;
for (const waiter of waiters) {
waiter(ctx);
}
waiters.clear();
}

export function getNativeRemoteExtensionContext(): ExtensionContext | null {
return nativeRemoteExtensionContext;
}

export function waitForNativeRemoteExtensionContext(
timeoutMs = 10_000
): Promise<ExtensionContext | null> {
if (nativeRemoteExtensionContext) {
return Promise.resolve(nativeRemoteExtensionContext);
}

return new Promise((resolve) => {
const waiter: ContextWaiter = (ctx) => {
clearTimeout(timer);
waiters.delete(waiter);
resolve(ctx);
};

const timer = setTimeout(() => {
waiters.delete(waiter);
resolve(nativeRemoteExtensionContext);
}, timeoutMs);

waiters.add(waiter);
});
}
2 changes: 1 addition & 1 deletion packages/coder/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface HubToolDefinition {
description: string;
parameters: Record<string, unknown>; // JSON Schema object
promptSnippet?: string;
promptGuidelines?: string;
promptGuidelines?: string | string[];
}

/** Command definition sent by Hub for agent routing slash commands. */
Expand Down
31 changes: 26 additions & 5 deletions packages/coder/src/remote-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ import {
InteractiveMode,
SessionManager,
} from '@mariozechner/pi-coding-agent';
import {
getNativeRemoteExtensionContext,
setNativeRemoteExtensionContext,
waitForNativeRemoteExtensionContext,
} from './native-remote-ui-context.ts';
import { RemoteSession } from './remote-session.ts';
import type { RpcEvent } from './remote-session.ts';
import { agentuityCoderHub } from './index.ts';
import { handleRemoteUiRequest, REMOTE_FIRE_AND_FORGET_UI_METHODS } from './remote-ui-handler.ts';

const DEBUG = !!process.env['AGENTUITY_DEBUG'];

Expand Down Expand Up @@ -63,6 +69,7 @@ export async function runRemoteTui(options: {
process.env.AGENTUITY_CODER_HUB_URL = hubWsUrl;
process.env.AGENTUITY_CODER_REMOTE_SESSION = sessionId;
process.env.AGENTUITY_CODER_NATIVE_REMOTE = '1';
setNativeRemoteExtensionContext(null);

// ── 1. Create RemoteSession (NOT connected yet) ──
// We register all handlers BEFORE connecting so that the hydration
Expand Down Expand Up @@ -460,11 +467,23 @@ export async function runRemoteTui(options: {

// ── 6. Wire up UI handlers for extension dialogs from sandbox ──
remote.setUiHandler(async (request) => {
// TODO: Bridge to InteractiveMode's extension UI context
log(`UI request: ${request.method} (no handler yet)`);
const fireAndForget = ['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text'];
if (fireAndForget.includes(request.method)) return undefined;
return null;
const ctx =
getNativeRemoteExtensionContext() ?? (await waitForNativeRemoteExtensionContext(10_000));
if (!ctx) {
log(
`UI request: ${request.method} (${request.id}) timed out waiting for extension UI context`
);
return REMOTE_FIRE_AND_FORGET_UI_METHODS.has(request.method) ? undefined : null;
}

try {
return await handleRemoteUiRequest(ctx, request);
} catch (err) {
log(
`UI request handler error for ${request.method}: ${err instanceof Error ? err.message : String(err)}`
);
return REMOTE_FIRE_AND_FORGET_UI_METHODS.has(request.method) ? undefined : null;
}
});

// ── 7. Handle hydration (initial state from Hub) ──
Expand Down Expand Up @@ -732,6 +751,7 @@ export async function runRemoteTui(options: {
// Handle clean shutdown
const cleanup = () => {
remote.close();
setNativeRemoteExtensionContext(null);
interactive.stop();
};
process.on('SIGINT', cleanup);
Expand All @@ -744,6 +764,7 @@ export async function runRemoteTui(options: {
throw err;
} finally {
remote.close();
setNativeRemoteExtensionContext(null);
log('Remote TUI exited');
}

Expand Down
86 changes: 86 additions & 0 deletions packages/coder/src/remote-ui-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
import type { RpcUiRequest } from './remote-session.ts';

export const REMOTE_FIRE_AND_FORGET_UI_METHODS = new Set([
'notify',
'setStatus',
'setWidget',
'setTitle',
'set_editor_text',
]);

export async function handleRemoteUiRequest(
ctx: ExtensionContext,
request: RpcUiRequest
): Promise<unknown> {
if (!ctx.hasUI) {
return REMOTE_FIRE_AND_FORGET_UI_METHODS.has(request.method) ? undefined : null;
}

const ui = ctx.ui;

switch (request.method) {
case 'select': {
const options = Array.isArray(request.params.options)
? request.params.options.filter(
(option): option is string => typeof option === 'string'
)
: [];
const title = (request.params.title as string) ?? 'Select';
return (await ui.select(title, options)) ?? null;
}
case 'confirm':
return await ui.confirm(
(request.params.title as string) ?? 'Confirm?',
(request.params.message as string) ?? 'Confirm?'
);
case 'input':
return (
(await ui.input(
(request.params.title as string) ?? 'Input',
(request.params.placeholder as string) ?? ''
)) ?? null
);
case 'editor':
return (
(await ui.editor(
(request.params.title as string) ?? 'Editor',
(request.params.prefill as string) ?? ''
)) ?? null
);
case 'notify':
ui.notify(
(request.params.message as string) ?? '',
(request.params.notifyType as 'info' | 'warning' | 'error' | undefined) ?? 'info'
);
return undefined;
case 'setStatus':
ui.setStatus(
(request.params.statusKey as string) ?? 'remote',
(request.params.statusText as string | undefined) ?? undefined
);
return undefined;
case 'setWidget': {
const widgetLines = Array.isArray(request.params.widgetLines)
? request.params.widgetLines.filter((line): line is string => typeof line === 'string')
: undefined;
const widgetPlacement = request.params.widgetPlacement;
ui.setWidget(
(request.params.widgetKey as string) ?? 'remote_widget',
widgetLines,
widgetPlacement === 'aboveEditor' || widgetPlacement === 'belowEditor'
? { placement: widgetPlacement }
: undefined
);
return undefined;
}
case 'setTitle':
ui.setTitle((request.params.title as string) ?? '');
return undefined;
case 'set_editor_text':
ui.setEditorText((request.params.text as string) ?? '');
return undefined;
default:
return null;
}
}
Loading
Loading