```
feat: add zodSchema wrapper for terminal tools list_terminals ```
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { stepCountIs, streamText, tool } from "ai";
|
import { stepCountIs, streamText, tool, zodSchema } from "ai";
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../daemon/terminal-manager.js";
|
} from "../daemon/terminal-manager.js";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { getPlaywrightTools } from "./playwright-mcp.js";
|
import { getPlaywrightTools } from "./playwright-mcp.js";
|
||||||
|
import { lazySchema } from "@ai-sdk/provider-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal tools using Vercel AI SDK tool() function
|
* Terminal tools using Vercel AI SDK tool() function
|
||||||
@@ -21,7 +22,7 @@ export const terminalTools = {
|
|||||||
list_terminals: tool({
|
list_terminals: tool({
|
||||||
description:
|
description:
|
||||||
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
|
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
|
||||||
inputSchema: z.object({}),
|
inputSchema: zodSchema(z.object({})),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const terminals = await listTerminals();
|
const terminals = await listTerminals();
|
||||||
return { terminals };
|
return { terminals };
|
||||||
@@ -248,6 +249,18 @@ export const terminalTools = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tls = {
|
||||||
|
list_terminals: tool({
|
||||||
|
description:
|
||||||
|
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const terminals = await listTerminals();
|
||||||
|
return { terminals };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache Playwright MCP tools (lazy loaded on first use)
|
* Cache Playwright MCP tools (lazy loaded on first use)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
```typescript
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "my-app",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple tool with parameters
|
||||||
|
server.registerTool(
|
||||||
|
"calculate-bmi",
|
||||||
|
{
|
||||||
|
title: "BMI Calculator",
|
||||||
|
description: "Calculate Body Mass Index",
|
||||||
|
inputSchema: {
|
||||||
|
weightKg: z.number(),
|
||||||
|
heightM: z.number(),
|
||||||
|
},
|
||||||
|
outputSchema: { bmi: z.number() },
|
||||||
|
},
|
||||||
|
async ({ weightKg, heightM }) => {
|
||||||
|
const output = { bmi: weightKg / (heightM * heightM) };
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(output),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Async tool with external API call
|
||||||
|
server.registerTool(
|
||||||
|
"fetch-weather",
|
||||||
|
{
|
||||||
|
title: "Weather Fetcher",
|
||||||
|
description: "Get weather data for a city",
|
||||||
|
inputSchema: { city: z.string() },
|
||||||
|
outputSchema: { temperature: z.number(), conditions: z.string() },
|
||||||
|
},
|
||||||
|
async ({ city }) => {
|
||||||
|
const response = await fetch(`https://api.weather.com/${city}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const output = { temperature: data.temp, conditions: data.conditions };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool that returns ResourceLinks
|
||||||
|
server.registerTool(
|
||||||
|
"list-files",
|
||||||
|
{
|
||||||
|
title: "List Files",
|
||||||
|
description: "List project files",
|
||||||
|
inputSchema: { pattern: z.string() },
|
||||||
|
outputSchema: {
|
||||||
|
count: z.number(),
|
||||||
|
files: z.array(z.object({ name: z.string(), uri: z.string() })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ pattern }) => {
|
||||||
|
const output = {
|
||||||
|
count: 2,
|
||||||
|
files: [
|
||||||
|
{ name: "README.md", uri: "file:///project/README.md" },
|
||||||
|
{ name: "index.ts", uri: "file:///project/src/index.ts" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: JSON.stringify(output) },
|
||||||
|
// ResourceLinks let tools return references without file content
|
||||||
|
{
|
||||||
|
type: "resource_link",
|
||||||
|
uri: "file:///project/README.md",
|
||||||
|
name: "README.md",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
description: "A README file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "resource_link",
|
||||||
|
uri: "file:///project/src/index.ts",
|
||||||
|
name: "index.ts",
|
||||||
|
mimeType: "text/typescript",
|
||||||
|
description: "An index file",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||||
|
|
||||||
|
bench('InMemoryTransport', async () => {
|
||||||
|
const client = new Client(
|
||||||
|
{
|
||||||
|
name: 'test client',
|
||||||
|
version: '1.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
const [clientTransport, serverTransport] =
|
||||||
|
InMemoryTransport.createLinkedPair();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
client.connect(clientTransport),
|
||||||
|
server.connect(serverTransport),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tools = await client.listTools();
|
||||||
|
if (!tools.tools.length) throw new Error('No tools found');
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
|
||||||
|
export { TerminalManager } from "./terminal-manager.js";
|
||||||
|
export type {
|
||||||
|
TerminalInfo,
|
||||||
|
CreateTerminalParams,
|
||||||
|
CreateTerminalResult,
|
||||||
|
} from "./terminal-manager.js";
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { TerminalManager } from "./terminal-manager.js";
|
||||||
|
|
||||||
|
export interface TerminalMcpServerOptions {
|
||||||
|
sessionName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure the Terminal MCP Server
|
||||||
|
* Multiple instances can run independently with different session names
|
||||||
|
*/
|
||||||
|
export async function createTerminalMcpServer(
|
||||||
|
options: TerminalMcpServerOptions
|
||||||
|
): Promise<McpServer> {
|
||||||
|
const { sessionName } = options;
|
||||||
|
const terminalManager = new TerminalManager(sessionName);
|
||||||
|
|
||||||
|
// Initialize the session
|
||||||
|
await terminalManager.initialize();
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "terminal-mcp",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool: list_terminals
|
||||||
|
server.registerTool(
|
||||||
|
"list_terminals",
|
||||||
|
{
|
||||||
|
title: "List Terminals",
|
||||||
|
description:
|
||||||
|
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
|
||||||
|
inputSchema: {},
|
||||||
|
outputSchema: {
|
||||||
|
terminals: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
workingDirectory: z.string(),
|
||||||
|
currentCommand: z.string(),
|
||||||
|
lastLines: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const terminals = await terminalManager.listTerminals();
|
||||||
|
const output = { terminals };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: create_terminal
|
||||||
|
server.registerTool(
|
||||||
|
"create_terminal",
|
||||||
|
{
|
||||||
|
title: "Create Terminal",
|
||||||
|
description:
|
||||||
|
"Create a new terminal (isolated shell environment) at a specific working directory. Optionally execute an initial command after creation. Terminal names must be unique. Always specify workingDirectory based on context - use project paths when working on projects, or the same directory as current terminal when user says 'another terminal here'. Defaults to ~ only if no context.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Unique name for the terminal. Should be descriptive of what the terminal is used for (e.g., 'web-dev', 'api-server', 'tests')."
|
||||||
|
),
|
||||||
|
workingDirectory: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Absolute path to the working directory for this terminal. Can use ~ for home directory. Required parameter - set contextually based on what the user is working on. Use project paths when working on projects. Defaults to home directory (~) only if no context."
|
||||||
|
),
|
||||||
|
initialCommand: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional command to execute after creating the terminal (e.g., 'npm run dev', 'python -m venv venv'). The command runs after changing to the working directory."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
terminal: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
workingDirectory: z.string(),
|
||||||
|
currentCommand: z.string(),
|
||||||
|
commandOutput: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name, workingDirectory, initialCommand }) => {
|
||||||
|
const terminal = await terminalManager.createTerminal({
|
||||||
|
name,
|
||||||
|
workingDirectory,
|
||||||
|
initialCommand,
|
||||||
|
});
|
||||||
|
const output = { terminal };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: capture_terminal
|
||||||
|
server.registerTool(
|
||||||
|
"capture_terminal",
|
||||||
|
{
|
||||||
|
title: "Capture Terminal",
|
||||||
|
description:
|
||||||
|
"Capture and return the output from a terminal. Returns the last N lines of terminal content. Useful for checking command results, monitoring running processes, or debugging issues.",
|
||||||
|
inputSchema: {
|
||||||
|
terminalName: z.string().describe("Name of the terminal"),
|
||||||
|
lines: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Number of lines to capture (default: 200)"),
|
||||||
|
maxWait: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Maximum milliseconds to wait for terminal activity to settle before capturing. Polls every 100ms and waits for 1s of no changes. Useful for commands with delayed output."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
output: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ terminalName, lines, maxWait }) => {
|
||||||
|
const output = await terminalManager.captureTerminal(
|
||||||
|
terminalName,
|
||||||
|
lines,
|
||||||
|
maxWait
|
||||||
|
);
|
||||||
|
const result = { output };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: send_text
|
||||||
|
server.registerTool(
|
||||||
|
"send_text",
|
||||||
|
{
|
||||||
|
title: "Send Text",
|
||||||
|
description:
|
||||||
|
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send_keys instead.",
|
||||||
|
inputSchema: {
|
||||||
|
terminalName: z.string().describe("Name of the terminal"),
|
||||||
|
text: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Text to type into the terminal. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
|
||||||
|
),
|
||||||
|
pressEnter: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
|
||||||
|
),
|
||||||
|
return_output: z
|
||||||
|
.object({
|
||||||
|
lines: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Number of lines to capture (default: 200)"),
|
||||||
|
waitForSettled: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
|
||||||
|
),
|
||||||
|
maxWait: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Capture terminal output after sending text. By default waits for activity to settle."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
output: z.string().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ terminalName, text, pressEnter, return_output }) => {
|
||||||
|
const output = await terminalManager.sendText(
|
||||||
|
terminalName,
|
||||||
|
text,
|
||||||
|
pressEnter,
|
||||||
|
return_output
|
||||||
|
);
|
||||||
|
const result = { output: output || undefined };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: send_keys
|
||||||
|
server.registerTool(
|
||||||
|
"send_keys",
|
||||||
|
{
|
||||||
|
title: "Send Keys",
|
||||||
|
description:
|
||||||
|
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send_text instead. Supports repeating key presses and optionally capturing output after sending keys.",
|
||||||
|
inputSchema: {
|
||||||
|
terminalName: z.string().describe("Name of the terminal"),
|
||||||
|
keys: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
|
||||||
|
),
|
||||||
|
repeat: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe("Number of times to repeat the key press (default: 1)"),
|
||||||
|
return_output: z
|
||||||
|
.object({
|
||||||
|
lines: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Number of lines to capture (default: 200)"),
|
||||||
|
waitForSettled: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
|
||||||
|
),
|
||||||
|
maxWait: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Capture terminal output after sending keys. By default waits for activity to settle."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
output: z.string().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ terminalName, keys, repeat, return_output }) => {
|
||||||
|
const output = await terminalManager.sendKeys(
|
||||||
|
terminalName,
|
||||||
|
keys,
|
||||||
|
repeat,
|
||||||
|
return_output
|
||||||
|
);
|
||||||
|
const result = { output: output || undefined };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: rename_terminal
|
||||||
|
server.registerTool(
|
||||||
|
"rename_terminal",
|
||||||
|
{
|
||||||
|
title: "Rename Terminal",
|
||||||
|
description:
|
||||||
|
"Rename a terminal to a more descriptive name. The new name must be unique among all terminals.",
|
||||||
|
inputSchema: {
|
||||||
|
terminalName: z.string().describe("Current name of the terminal"),
|
||||||
|
newName: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"New unique name for the terminal. Should be descriptive of the terminal's purpose."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ terminalName, newName }) => {
|
||||||
|
await terminalManager.renameTerminal(terminalName, newName);
|
||||||
|
const output = { success: true };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool: kill_terminal
|
||||||
|
server.registerTool(
|
||||||
|
"kill_terminal",
|
||||||
|
{
|
||||||
|
title: "Kill Terminal",
|
||||||
|
description:
|
||||||
|
"Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.",
|
||||||
|
inputSchema: {
|
||||||
|
terminalName: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Name of the terminal to kill. Get this from list_terminals."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
success: z.boolean(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ terminalName }) => {
|
||||||
|
await terminalManager.killTerminal(terminalName);
|
||||||
|
const output = { success: true };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||||
|
structuredContent: output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import {
|
||||||
|
findSessionByName,
|
||||||
|
createSession,
|
||||||
|
listWindows,
|
||||||
|
createWindow,
|
||||||
|
listPanes,
|
||||||
|
findWindowByName,
|
||||||
|
capturePaneContent,
|
||||||
|
sendText as tmuxSendText,
|
||||||
|
sendKeys as tmuxSendKeys,
|
||||||
|
renameWindow,
|
||||||
|
killWindow,
|
||||||
|
isWindowNameUnique,
|
||||||
|
getCurrentWorkingDirectory,
|
||||||
|
getCurrentCommand,
|
||||||
|
waitForPaneActivityToSettle,
|
||||||
|
} from "./tmux.js";
|
||||||
|
|
||||||
|
// Terminal model: session → windows (single pane per window)
|
||||||
|
// Terminals are identified by their unique names, not IDs
|
||||||
|
|
||||||
|
export interface TerminalInfo {
|
||||||
|
name: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
currentCommand: string;
|
||||||
|
lastLines?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTerminalParams {
|
||||||
|
name: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
initialCommand?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTerminalResult extends TerminalInfo {
|
||||||
|
commandOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal manager for a specific tmux session
|
||||||
|
* Multiple instances can coexist with different session names
|
||||||
|
*/
|
||||||
|
export class TerminalManager {
|
||||||
|
constructor(private sessionName: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the tmux session
|
||||||
|
* Creates it if it doesn't exist
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await createSession(this.sessionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all terminals in the session
|
||||||
|
* Returns terminal info including name, active status, working directory, and current command
|
||||||
|
*/
|
||||||
|
async listTerminals(): Promise<TerminalInfo[]> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(
|
||||||
|
`Session '${this.sessionName}' not found. Call initialize() first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
|
||||||
|
const terminals: TerminalInfo[] = [];
|
||||||
|
|
||||||
|
for (const window of windows) {
|
||||||
|
// Get the first (and only) pane in this window
|
||||||
|
const paneId = `${window.id}.0`;
|
||||||
|
|
||||||
|
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||||
|
const currentCommand = await getCurrentCommand(paneId);
|
||||||
|
const lastLines = await capturePaneContent(paneId, 5, false);
|
||||||
|
|
||||||
|
terminals.push({
|
||||||
|
name: window.name,
|
||||||
|
workingDirectory,
|
||||||
|
currentCommand,
|
||||||
|
lastLines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new terminal (tmux window) with specified name and working directory
|
||||||
|
* Optionally execute an initial command
|
||||||
|
*/
|
||||||
|
async createTerminal(
|
||||||
|
params: CreateTerminalParams
|
||||||
|
): Promise<CreateTerminalResult> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(
|
||||||
|
`Session '${this.sessionName}' not found. Call initialize() first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name uniqueness
|
||||||
|
const isUnique = await isWindowNameUnique(session.id, params.name);
|
||||||
|
if (!isUnique) {
|
||||||
|
throw new Error(
|
||||||
|
`Terminal with name '${params.name}' already exists. Please choose a unique name.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the window
|
||||||
|
const windowResult = await createWindow(session.id, params.name, {
|
||||||
|
workingDirectory: params.workingDirectory,
|
||||||
|
command: params.initialCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!windowResult) {
|
||||||
|
throw new Error(`Failed to create terminal '${params.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paneId = windowResult.paneId;
|
||||||
|
|
||||||
|
// Get terminal info
|
||||||
|
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||||
|
const currentCommand = await getCurrentCommand(paneId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: windowResult.name,
|
||||||
|
workingDirectory,
|
||||||
|
currentCommand,
|
||||||
|
commandOutput: windowResult.output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture output from a terminal by name
|
||||||
|
* Returns the last N lines of terminal content
|
||||||
|
* If maxWait is provided, waits for terminal activity to settle before capturing
|
||||||
|
*/
|
||||||
|
async captureTerminal(
|
||||||
|
terminalName: string,
|
||||||
|
lines: number = 200,
|
||||||
|
maxWait?: number
|
||||||
|
): Promise<string> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve terminal name to window
|
||||||
|
const window = await findWindowByName(session.id, terminalName);
|
||||||
|
if (!window) {
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
const availableNames = windows.map((w) => w.name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first pane
|
||||||
|
const panes = await listPanes(window.id);
|
||||||
|
const pane = panes[0];
|
||||||
|
if (!pane) {
|
||||||
|
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for activity to settle if maxWait is provided
|
||||||
|
if (maxWait) {
|
||||||
|
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return capturePaneContent(pane.id, lines, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send text to a terminal by name, optionally press Enter, optionally return output
|
||||||
|
*/
|
||||||
|
async sendText(
|
||||||
|
terminalName: string,
|
||||||
|
text: string,
|
||||||
|
pressEnter: boolean = false,
|
||||||
|
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
|
||||||
|
): Promise<string | void> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve terminal name to window
|
||||||
|
const window = await findWindowByName(session.id, terminalName);
|
||||||
|
if (!window) {
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
const availableNames = windows.map((w) => w.name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first pane
|
||||||
|
const panes = await listPanes(window.id);
|
||||||
|
const pane = panes[0];
|
||||||
|
if (!pane) {
|
||||||
|
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmuxSendText({
|
||||||
|
paneId: pane.id,
|
||||||
|
text,
|
||||||
|
pressEnter,
|
||||||
|
return_output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send special keys or key combinations to a terminal by name
|
||||||
|
* Useful for TUI navigation, control sequences, and interactive applications
|
||||||
|
*/
|
||||||
|
async sendKeys(
|
||||||
|
terminalName: string,
|
||||||
|
keys: string,
|
||||||
|
repeat: number = 1,
|
||||||
|
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
|
||||||
|
): Promise<string | void> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve terminal name to window
|
||||||
|
const window = await findWindowByName(session.id, terminalName);
|
||||||
|
if (!window) {
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
const availableNames = windows.map((w) => w.name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first pane
|
||||||
|
const panes = await listPanes(window.id);
|
||||||
|
const pane = panes[0];
|
||||||
|
if (!pane) {
|
||||||
|
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmuxSendKeys({
|
||||||
|
paneId: pane.id,
|
||||||
|
keys,
|
||||||
|
repeat,
|
||||||
|
return_output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a terminal by name
|
||||||
|
* Validates that the new name is unique
|
||||||
|
*/
|
||||||
|
async renameTerminal(
|
||||||
|
terminalName: string,
|
||||||
|
newName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// renameWindow handles uniqueness validation and name resolution internally
|
||||||
|
await renameWindow(session.id, terminalName, newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill (close/destroy) a terminal by name
|
||||||
|
*/
|
||||||
|
async killTerminal(terminalName: string): Promise<void> {
|
||||||
|
const session = await findSessionByName(this.sessionName);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve terminal name to window
|
||||||
|
const window = await findWindowByName(session.id, terminalName);
|
||||||
|
if (!window) {
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
const availableNames = windows.map((w) => w.name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await killWindow(window.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,923 @@
|
|||||||
|
import { exec as execCallback } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
const exec = promisify(execCallback);
|
||||||
|
|
||||||
|
// Basic interfaces for tmux objects
|
||||||
|
export interface TmuxSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
attached: boolean;
|
||||||
|
windows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxWindow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxPane {
|
||||||
|
id: string;
|
||||||
|
windowId: string;
|
||||||
|
active: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandExecution {
|
||||||
|
id: string;
|
||||||
|
paneId: string;
|
||||||
|
command: string;
|
||||||
|
status: "pending" | "completed" | "error";
|
||||||
|
startTime: Date;
|
||||||
|
result?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
rawMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShellType = "bash" | "zsh" | "fish";
|
||||||
|
|
||||||
|
let shellConfig: { type: ShellType } = { type: "bash" };
|
||||||
|
|
||||||
|
export function setShellConfig(config: { type: string }): void {
|
||||||
|
// Validate shell type
|
||||||
|
const validShells: ShellType[] = ["bash", "zsh", "fish"];
|
||||||
|
|
||||||
|
if (validShells.includes(config.type as ShellType)) {
|
||||||
|
shellConfig = { type: config.type as ShellType };
|
||||||
|
} else {
|
||||||
|
shellConfig = { type: "bash" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a tmux command and return the result
|
||||||
|
*/
|
||||||
|
export async function executeTmux(tmuxCommand: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec(`tmux ${tmuxCommand}`);
|
||||||
|
return stdout.trim();
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tmux server is running
|
||||||
|
*/
|
||||||
|
export async function isTmuxRunning(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await executeTmux("list-sessions -F '#{session_name}'");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tmux sessions
|
||||||
|
*/
|
||||||
|
export async function listSessions(): Promise<TmuxSession[]> {
|
||||||
|
const format =
|
||||||
|
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
||||||
|
const output = await executeTmux(`list-sessions -F '${format}'`);
|
||||||
|
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
return output.split("\n").map((line) => {
|
||||||
|
const [id, name, attached, windows] = line.split(":");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
attached: attached === "1",
|
||||||
|
windows: parseInt(windows, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a session by name
|
||||||
|
*/
|
||||||
|
export async function findSessionByName(
|
||||||
|
name: string
|
||||||
|
): Promise<TmuxSession | null> {
|
||||||
|
try {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
return sessions.find((session) => session.name === name) || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a window by name in a session
|
||||||
|
*/
|
||||||
|
export async function findWindowByName(
|
||||||
|
sessionId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<TmuxWindow | null> {
|
||||||
|
try {
|
||||||
|
const windows = await listWindows(sessionId);
|
||||||
|
return windows.find((window) => window.name === name) || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a window name is unique in a session
|
||||||
|
*/
|
||||||
|
export async function isWindowNameUnique(
|
||||||
|
sessionId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const window = await findWindowByName(sessionId, name);
|
||||||
|
return window === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List windows in a session
|
||||||
|
*/
|
||||||
|
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
||||||
|
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
||||||
|
const output = await executeTmux(
|
||||||
|
`list-windows -t '${sessionId}' -F '${format}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
return output.split("\n").map((line) => {
|
||||||
|
const [id, name, active] = line.split(":");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
active: active === "1",
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List panes in a window
|
||||||
|
*/
|
||||||
|
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
|
||||||
|
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
||||||
|
const output = await executeTmux(
|
||||||
|
`list-panes -t '${windowId}' -F '${format}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
return output.split("\n").map((line) => {
|
||||||
|
const [id, title, active] = line.split(":");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
windowId,
|
||||||
|
title: title,
|
||||||
|
active: active === "1",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture content from a specific pane, by default the latest 200 lines.
|
||||||
|
*/
|
||||||
|
export async function capturePaneContent(
|
||||||
|
paneId: string,
|
||||||
|
lines: number = 200,
|
||||||
|
includeColors: boolean = false
|
||||||
|
): Promise<string> {
|
||||||
|
const colorFlag = includeColors ? "-e" : "";
|
||||||
|
// Capture a large range to ensure we have enough content
|
||||||
|
const captureLines = Math.max(lines, 1000);
|
||||||
|
const output = await executeTmux(
|
||||||
|
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${captureLines} -E -`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trim trailing whitespace, split by lines, take last N lines, rejoin
|
||||||
|
const trimmed = output.trimEnd();
|
||||||
|
const allLines = trimmed.split("\n");
|
||||||
|
const lastLines = allLines.slice(-lines);
|
||||||
|
|
||||||
|
return lastLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current working directory of a pane
|
||||||
|
*/
|
||||||
|
export async function getCurrentWorkingDirectory(
|
||||||
|
paneId: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const tmuxPath = await executeTmux(
|
||||||
|
`display-message -p -t '${paneId}' '#{pane_current_path}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If tmux returns a valid path, use it
|
||||||
|
if (tmuxPath && tmuxPath.trim()) {
|
||||||
|
return tmuxPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: get the PID and use lsof to find the actual CWD
|
||||||
|
const shellPid = await executeTmux(
|
||||||
|
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
||||||
|
);
|
||||||
|
const { stdout } = await exec(
|
||||||
|
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
|
||||||
|
);
|
||||||
|
return stdout.trim() || tmuxPath;
|
||||||
|
} catch (error) {
|
||||||
|
// If all else fails, return empty string
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current command running in a pane (full command line with arguments)
|
||||||
|
* Gets the immediate child process of the shell, not the shell itself
|
||||||
|
*/
|
||||||
|
export async function getCurrentCommand(paneId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Get the shell PID (the pane's main process)
|
||||||
|
const shellPid = await executeTmux(
|
||||||
|
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// First, check if there's a child process using comm= (works for all programs including top)
|
||||||
|
// Use 'ax' flags to see all processes
|
||||||
|
const { stdout: childPid } = await exec(
|
||||||
|
`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (childPid.trim()) {
|
||||||
|
// Found a child process, get its full command with args
|
||||||
|
const { stdout: fullCmd } = await exec(
|
||||||
|
`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`
|
||||||
|
);
|
||||||
|
const command = fullCmd.trim();
|
||||||
|
if (command) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No child process, just return the shell name
|
||||||
|
const { stdout: shellCmd } = await exec(`ps -p ${shellPid} -o comm=`);
|
||||||
|
return shellCmd.trim();
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to just the command name if ps fails
|
||||||
|
return executeTmux(
|
||||||
|
`display-message -p -t '${paneId}' '#{pane_current_command}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tmux session with a default window named "default" in home directory
|
||||||
|
*/
|
||||||
|
export async function createSession(name: string): Promise<TmuxSession | null> {
|
||||||
|
const homeDir = process.env.HOME || "~";
|
||||||
|
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
|
||||||
|
|
||||||
|
// Disable automatic window renaming for all windows in the session
|
||||||
|
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
|
||||||
|
|
||||||
|
return findSessionByName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand tilde in path to home directory
|
||||||
|
*/
|
||||||
|
function expandTilde(path: string): string {
|
||||||
|
if (path.startsWith("~/")) {
|
||||||
|
const homeDir = process.env.HOME || os.homedir();
|
||||||
|
return path.replace("~", homeDir);
|
||||||
|
}
|
||||||
|
if (path === "~") {
|
||||||
|
return process.env.HOME || os.homedir();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new window in a session with optional working directory and initial command
|
||||||
|
*/
|
||||||
|
export async function createWindow(
|
||||||
|
sessionId: string,
|
||||||
|
name: string,
|
||||||
|
options?: {
|
||||||
|
workingDirectory?: string;
|
||||||
|
command?: string;
|
||||||
|
}
|
||||||
|
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
|
||||||
|
// Validate name uniqueness
|
||||||
|
const isUnique = await isWindowNameUnique(sessionId, name);
|
||||||
|
if (!isUnique) {
|
||||||
|
throw new Error(
|
||||||
|
`Terminal with name '${name}' already exists. Please choose a unique name.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new-window command with optional working directory
|
||||||
|
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
|
||||||
|
if (options?.workingDirectory) {
|
||||||
|
// Expand tilde to home directory before passing to tmux
|
||||||
|
const expandedPath = expandTilde(options.workingDirectory);
|
||||||
|
newWindowCmd += ` -c '${expandedPath}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeTmux(newWindowCmd);
|
||||||
|
const windows = await listWindows(sessionId);
|
||||||
|
const window = windows.find((window) => window.name === name);
|
||||||
|
|
||||||
|
if (!window) return null;
|
||||||
|
|
||||||
|
// Disable automatic window renaming
|
||||||
|
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
|
||||||
|
|
||||||
|
// Get the default pane created with the window
|
||||||
|
const panes = await listPanes(window.id);
|
||||||
|
const defaultPane = panes[0];
|
||||||
|
|
||||||
|
let commandOutput: string | undefined;
|
||||||
|
|
||||||
|
// If command is provided, execute it in the new pane
|
||||||
|
if (options?.command && defaultPane) {
|
||||||
|
commandOutput = (await sendText({
|
||||||
|
paneId: defaultPane.id,
|
||||||
|
text: options.command,
|
||||||
|
pressEnter: true,
|
||||||
|
return_output: {
|
||||||
|
waitForSettled: true,
|
||||||
|
maxWait: 120000,
|
||||||
|
},
|
||||||
|
})) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...window,
|
||||||
|
paneId: defaultPane?.id || "",
|
||||||
|
output: commandOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a tmux session by ID
|
||||||
|
*/
|
||||||
|
export async function killSession(sessionId: string): Promise<void> {
|
||||||
|
await executeTmux(`kill-session -t '${sessionId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a tmux window by ID
|
||||||
|
*/
|
||||||
|
export async function killWindow(windowId: string): Promise<void> {
|
||||||
|
await executeTmux(`kill-window -t '${windowId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a tmux pane by ID
|
||||||
|
*/
|
||||||
|
export async function killPane(paneId: string): Promise<void> {
|
||||||
|
await executeTmux(`kill-pane -t '${paneId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a tmux window by name or ID
|
||||||
|
*/
|
||||||
|
export async function renameWindow(
|
||||||
|
sessionId: string,
|
||||||
|
windowNameOrId: string,
|
||||||
|
newName: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate new name is unique
|
||||||
|
const isUnique = await isWindowNameUnique(sessionId, newName);
|
||||||
|
if (!isUnique) {
|
||||||
|
throw new Error(
|
||||||
|
`Terminal with name '${newName}' already exists. Please choose a unique name.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if windowNameOrId is a window ID (starts with @) or a name
|
||||||
|
let windowId: string;
|
||||||
|
if (windowNameOrId.startsWith("@")) {
|
||||||
|
windowId = windowNameOrId;
|
||||||
|
} else {
|
||||||
|
// Resolve name to ID
|
||||||
|
const window = await findWindowByName(sessionId, windowNameOrId);
|
||||||
|
if (!window) {
|
||||||
|
throw new Error(`Terminal '${windowNameOrId}' not found.`);
|
||||||
|
}
|
||||||
|
windowId = window.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
|
||||||
|
// Disable automatic renaming to preserve the manual name
|
||||||
|
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a tmux pane horizontally or vertically
|
||||||
|
*/
|
||||||
|
export async function splitPane(
|
||||||
|
targetPaneId: string,
|
||||||
|
direction: "horizontal" | "vertical" = "vertical",
|
||||||
|
size?: number
|
||||||
|
): Promise<TmuxPane | null> {
|
||||||
|
// Build the split-window command
|
||||||
|
let splitCommand = "split-window";
|
||||||
|
|
||||||
|
// Add direction flag (-h for horizontal, -v for vertical)
|
||||||
|
if (direction === "horizontal") {
|
||||||
|
splitCommand += " -h";
|
||||||
|
} else {
|
||||||
|
splitCommand += " -v";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add target pane
|
||||||
|
splitCommand += ` -t '${targetPaneId}'`;
|
||||||
|
|
||||||
|
// Add size if specified (as percentage)
|
||||||
|
if (size !== undefined && size > 0 && size < 100) {
|
||||||
|
splitCommand += ` -p ${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the split command
|
||||||
|
await executeTmux(splitCommand);
|
||||||
|
|
||||||
|
// Get the window ID from the target pane to list all panes
|
||||||
|
const windowInfo = await executeTmux(
|
||||||
|
`display-message -p -t '${targetPaneId}' '#{window_id}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// List all panes in the window to find the newly created one
|
||||||
|
const panes = await listPanes(windowInfo);
|
||||||
|
|
||||||
|
// The newest pane is typically the last one in the list
|
||||||
|
return panes.length > 0 ? panes[panes.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to track ongoing command executions
|
||||||
|
const activeCommands = new Map<string, CommandExecution>();
|
||||||
|
|
||||||
|
const startMarkerText = "TMUX_MCP_START";
|
||||||
|
const endMarkerPrefix = "TMUX_MCP_DONE_";
|
||||||
|
|
||||||
|
// Execute a command in a tmux pane and track its execution
|
||||||
|
export async function executeCommand(
|
||||||
|
paneId: string,
|
||||||
|
command: string,
|
||||||
|
rawMode?: boolean,
|
||||||
|
noEnter?: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
// Generate unique ID for this command execution
|
||||||
|
const commandId = uuidv4();
|
||||||
|
|
||||||
|
let fullCommand: string;
|
||||||
|
if (rawMode || noEnter) {
|
||||||
|
fullCommand = command;
|
||||||
|
} else {
|
||||||
|
const endMarkerText = getEndMarkerText();
|
||||||
|
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store command in tracking map
|
||||||
|
activeCommands.set(commandId, {
|
||||||
|
id: commandId,
|
||||||
|
paneId,
|
||||||
|
command,
|
||||||
|
status: "pending",
|
||||||
|
startTime: new Date(),
|
||||||
|
rawMode: rawMode || noEnter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the command to the tmux pane
|
||||||
|
if (noEnter) {
|
||||||
|
// Check if this is a special key or key combination
|
||||||
|
// Special keys in tmux are typically capitalized or have special names
|
||||||
|
const specialKeys = [
|
||||||
|
"Up",
|
||||||
|
"Down",
|
||||||
|
"Left",
|
||||||
|
"Right",
|
||||||
|
"Escape",
|
||||||
|
"Tab",
|
||||||
|
"Enter",
|
||||||
|
"Space",
|
||||||
|
"BSpace",
|
||||||
|
"Delete",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
"F6",
|
||||||
|
"F7",
|
||||||
|
"F8",
|
||||||
|
"F9",
|
||||||
|
"F10",
|
||||||
|
"F11",
|
||||||
|
"F12",
|
||||||
|
"BTab",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Split the command into parts to handle combinations like "C-b" or "M-x"
|
||||||
|
const parts = fullCommand.split("-");
|
||||||
|
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
|
||||||
|
const isKeyCombo =
|
||||||
|
parts.length > 1 &&
|
||||||
|
(parts[0] === "C" || // Control
|
||||||
|
parts[0] === "M" || // Meta/Alt
|
||||||
|
parts[0] === "S"); // Shift
|
||||||
|
|
||||||
|
if (isSpecialKey || isKeyCombo) {
|
||||||
|
// Send special key or key combination as-is
|
||||||
|
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
|
||||||
|
} else {
|
||||||
|
// For regular text, send each character individually to ensure proper processing
|
||||||
|
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
||||||
|
for (const char of fullCommand) {
|
||||||
|
await executeTmux(
|
||||||
|
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await executeTmux(
|
||||||
|
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkCommandStatus(
|
||||||
|
commandId: string
|
||||||
|
): Promise<CommandExecution | null> {
|
||||||
|
const command = activeCommands.get(commandId);
|
||||||
|
if (!command) return null;
|
||||||
|
|
||||||
|
if (command.status !== "pending") return command;
|
||||||
|
|
||||||
|
const content = await capturePaneContent(command.paneId, 1000);
|
||||||
|
|
||||||
|
if (command.rawMode) {
|
||||||
|
command.result =
|
||||||
|
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last occurrence of the markers
|
||||||
|
const startIndex = content.lastIndexOf(startMarkerText);
|
||||||
|
const endIndex = content.lastIndexOf(endMarkerPrefix);
|
||||||
|
|
||||||
|
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||||
|
command.result = "Command output could not be captured properly";
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract exit code from the end marker line
|
||||||
|
const endLine = content.substring(endIndex).split("\n")[0];
|
||||||
|
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
|
||||||
|
const exitCodeMatch = endLine.match(endMarkerRegex);
|
||||||
|
|
||||||
|
if (exitCodeMatch) {
|
||||||
|
const exitCode = parseInt(exitCodeMatch[1], 10);
|
||||||
|
|
||||||
|
command.status = exitCode === 0 ? "completed" : "error";
|
||||||
|
command.exitCode = exitCode;
|
||||||
|
|
||||||
|
// Extract output between the start and end markers
|
||||||
|
const outputStart = startIndex + startMarkerText.length;
|
||||||
|
const outputContent = content.substring(outputStart, endIndex).trim();
|
||||||
|
|
||||||
|
command.result = outputContent
|
||||||
|
.substring(outputContent.indexOf("\n") + 1)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Update in map
|
||||||
|
activeCommands.set(commandId, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get command by ID
|
||||||
|
export function getCommand(commandId: string): CommandExecution | null {
|
||||||
|
return activeCommands.get(commandId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active command IDs
|
||||||
|
export function getActiveCommandIds(): string[] {
|
||||||
|
return Array.from(activeCommands.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up completed commands older than a certain time
|
||||||
|
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const [id, command] of activeCommands.entries()) {
|
||||||
|
const ageMinutes =
|
||||||
|
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
|
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
|
||||||
|
activeCommands.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEndMarkerText(): string {
|
||||||
|
return shellConfig.type === "fish"
|
||||||
|
? `${endMarkerPrefix}$status`
|
||||||
|
: `${endMarkerPrefix}$?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New consolidated API functions
|
||||||
|
|
||||||
|
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
|
||||||
|
|
||||||
|
interface SessionWithWindows extends TmuxSession {
|
||||||
|
windowDetails?: WindowWithPanes[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowWithPanes extends TmuxWindow {
|
||||||
|
paneDetails?: TmuxPane[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list({
|
||||||
|
scope,
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
scope: ListScope;
|
||||||
|
target?: string;
|
||||||
|
}): Promise<
|
||||||
|
| SessionWithWindows[]
|
||||||
|
| TmuxSession[]
|
||||||
|
| TmuxWindow[]
|
||||||
|
| TmuxPane[]
|
||||||
|
| TmuxSession
|
||||||
|
| TmuxWindow
|
||||||
|
| TmuxPane
|
||||||
|
> {
|
||||||
|
if (scope === "all") {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
const sessionsWithDetails: SessionWithWindows[] = [];
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const windows = await listWindows(session.id);
|
||||||
|
const windowsWithPanes: WindowWithPanes[] = [];
|
||||||
|
|
||||||
|
for (const window of windows) {
|
||||||
|
const panes = await listPanes(window.id);
|
||||||
|
windowsWithPanes.push({
|
||||||
|
...window,
|
||||||
|
paneDetails: panes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsWithDetails.push({
|
||||||
|
...session,
|
||||||
|
windowDetails: windowsWithPanes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionsWithDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "sessions") {
|
||||||
|
return listSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "session") {
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("target is required for scope 'session'");
|
||||||
|
}
|
||||||
|
return listWindows(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "window") {
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("target is required for scope 'window'");
|
||||||
|
}
|
||||||
|
return listPanes(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "pane") {
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("target is required for scope 'pane'");
|
||||||
|
}
|
||||||
|
const windowId = await executeTmux(
|
||||||
|
`display-message -p -t '${target}' '#{window_id}'`
|
||||||
|
);
|
||||||
|
const panes = await listPanes(windowId);
|
||||||
|
const pane = panes.find((p) => p.id === target);
|
||||||
|
if (!pane) {
|
||||||
|
throw new Error(`Pane not found: ${target}`);
|
||||||
|
}
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid scope: ${scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KillScope = "session" | "window" | "pane";
|
||||||
|
|
||||||
|
export async function kill({
|
||||||
|
scope,
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
scope: KillScope;
|
||||||
|
target: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (scope === "session") {
|
||||||
|
return killSession(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "window") {
|
||||||
|
return killWindow(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "pane") {
|
||||||
|
return killPane(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid scope: ${scope}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellCommandResult {
|
||||||
|
command: string;
|
||||||
|
status: "completed" | "error";
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeShellCommand({
|
||||||
|
paneId,
|
||||||
|
command,
|
||||||
|
timeout = 30000,
|
||||||
|
}: {
|
||||||
|
paneId: string;
|
||||||
|
command: string;
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<ShellCommandResult> {
|
||||||
|
const commandId = uuidv4();
|
||||||
|
const endMarkerText = getEndMarkerText();
|
||||||
|
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||||
|
|
||||||
|
activeCommands.set(commandId, {
|
||||||
|
id: commandId,
|
||||||
|
paneId,
|
||||||
|
command,
|
||||||
|
status: "pending",
|
||||||
|
startTime: new Date(),
|
||||||
|
rawMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeTmux(
|
||||||
|
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll for completion
|
||||||
|
const startTime = Date.now();
|
||||||
|
const pollInterval = 100;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||||
|
|
||||||
|
const result = await checkCommandStatus(commandId);
|
||||||
|
|
||||||
|
if (result && result.status !== "pending") {
|
||||||
|
// Cleanup
|
||||||
|
activeCommands.delete(commandId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: result.command,
|
||||||
|
status: result.status,
|
||||||
|
exitCode: result.exitCode!,
|
||||||
|
output: result.result || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
activeCommands.delete(commandId);
|
||||||
|
throw new Error(
|
||||||
|
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendKeys({
|
||||||
|
paneId,
|
||||||
|
keys,
|
||||||
|
repeat = 1,
|
||||||
|
return_output,
|
||||||
|
}: {
|
||||||
|
paneId: string;
|
||||||
|
keys: string;
|
||||||
|
repeat?: number;
|
||||||
|
return_output?: {
|
||||||
|
lines?: number;
|
||||||
|
waitForSettled?: boolean;
|
||||||
|
maxWait?: number;
|
||||||
|
};
|
||||||
|
}): Promise<string | void> {
|
||||||
|
// Repeat the key press the specified number of times
|
||||||
|
for (let i = 0; i < repeat; i++) {
|
||||||
|
// Raw pass-through, no validation or processing
|
||||||
|
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If return_output is requested, wait and capture pane content
|
||||||
|
if (return_output) {
|
||||||
|
const lines = return_output.lines || 200;
|
||||||
|
const waitForSettled = return_output.waitForSettled ?? true;
|
||||||
|
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
|
||||||
|
|
||||||
|
if (waitForSettled) {
|
||||||
|
return waitForPaneActivityToSettle(paneId, maxWait, lines);
|
||||||
|
} else {
|
||||||
|
return capturePaneContent(paneId, lines, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForPaneActivityToSettle(
|
||||||
|
paneId: string,
|
||||||
|
maxWait: number,
|
||||||
|
lines: number
|
||||||
|
): Promise<string> {
|
||||||
|
const settleTime = 1000; // Hardcoded debounce
|
||||||
|
const pollInterval = 100; // Poll every 100ms
|
||||||
|
|
||||||
|
let lastContent = "";
|
||||||
|
let lastChangeTime = Date.now();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
if (elapsed >= maxWait) {
|
||||||
|
// Timeout - return what we have
|
||||||
|
return lastContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await capturePaneContent(paneId, lines, false);
|
||||||
|
|
||||||
|
if (content !== lastContent) {
|
||||||
|
// Activity detected - reset settle timer
|
||||||
|
lastContent = content;
|
||||||
|
lastChangeTime = Date.now();
|
||||||
|
} else if (Date.now() - lastChangeTime >= settleTime) {
|
||||||
|
// No changes for settleTime ms - settled!
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendText({
|
||||||
|
paneId,
|
||||||
|
text,
|
||||||
|
pressEnter = false,
|
||||||
|
return_output,
|
||||||
|
}: {
|
||||||
|
paneId: string;
|
||||||
|
text: string;
|
||||||
|
pressEnter?: boolean;
|
||||||
|
return_output?: {
|
||||||
|
lines?: number;
|
||||||
|
waitForSettled?: boolean;
|
||||||
|
maxWait?: number;
|
||||||
|
};
|
||||||
|
}): Promise<string | void> {
|
||||||
|
// Send each character with -l flag for literal interpretation
|
||||||
|
for (const char of text) {
|
||||||
|
await executeTmux(
|
||||||
|
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pressEnter) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
await executeTmux(`send-keys -t '${paneId}' Enter`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If return_output is requested, wait and capture pane content
|
||||||
|
if (return_output) {
|
||||||
|
const lines = return_output.lines || 200;
|
||||||
|
const waitForSettled = return_output.waitForSettled ?? true;
|
||||||
|
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
|
||||||
|
|
||||||
|
if (waitForSettled) {
|
||||||
|
return waitForPaneActivityToSettle(paneId, maxWait, lines);
|
||||||
|
} else {
|
||||||
|
return capturePaneContent(paneId, lines, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user