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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
42 changes: 35 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand DownExpand Up@@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand DownExpand Up@@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand DownExpand Up@@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All@@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All@@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand DownExpand Up@@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All@@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand DownExpand Up@@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading