refactor: enhance artifact system, security, and terminal command handling
- Add basic HTTP authentication (express-basic-auth) - Improve artifact tool with better source type handling (command_output, file, text) - Update agent prompt with clearer artifact usage patterns - Refactor tmux command execution to use execFile for safer argument handling - Add UI cancellation capability for in-progress operations - Switch session dumps to use util.inspect for better formatting - Update artifact tool schema to require title parameter first 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
"WebFetch(domain:vite.dev)",
|
"WebFetch(domain:vite.dev)",
|
||||||
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/hooks/**)",
|
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/hooks/**)",
|
||||||
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/**)",
|
"Read(//Users/moboudra/dev/voice-dev/packages/web/app/**)",
|
||||||
"Read(//Users/moboudra/dev/voice-dev/**)"
|
"Read(//Users/moboudra/dev/voice-dev/**)",
|
||||||
|
"Bash(cat:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -411,16 +411,38 @@ You: create-terminal(name="authentication", workingDirectory="WHATEVER THE CURRE
|
|||||||
|
|
||||||
**When user says "show me", keep your voice response SHORT and use present_artifact for the actual data.**
|
**When user says "show me", keep your voice response SHORT and use present_artifact for the actual data.**
|
||||||
|
|
||||||
**Pattern:**
|
**CRITICAL: Use command_output or file sources - don't run commands yourself then pass as text.**
|
||||||
|
|
||||||
|
**Pattern for commands:**
|
||||||
|
|
||||||
|
User: "Show me the git diff"
|
||||||
|
You: "Here's the diff."
|
||||||
|
[Call present_artifact with source: { type: "command_output", command: "git diff" }]
|
||||||
|
|
||||||
|
User: "Show me package.json"
|
||||||
|
You: "Here's package.json."
|
||||||
|
[Call present_artifact with source: { type: "file", path: "/path/to/package.json" }]
|
||||||
|
|
||||||
|
**Only use text source for data you already have (like terminal output you just captured):**
|
||||||
|
|
||||||
User: "Show me the terminal output"
|
User: "Show me the terminal output"
|
||||||
You: [Capture terminal output]
|
You: [Capture terminal output via capture-terminal]
|
||||||
You: "Here's the terminal output."
|
You: "Here's the terminal output."
|
||||||
[Call present_artifact with the output]
|
[Call present_artifact with source: { type: "text", text: capturedOutput }]
|
||||||
|
|
||||||
User: "Show me the inbox items"
|
**Bad pattern (don't do this):**
|
||||||
You: "Here's the inbox."
|
|
||||||
[Call present_artifact with the items]
|
User: "Show me the git diff"
|
||||||
|
You: [Run git diff command via send-text]
|
||||||
|
You: [Capture output]
|
||||||
|
You: [Call present_artifact with text source containing the output]
|
||||||
|
❌ Wrong - should use command_output source instead
|
||||||
|
|
||||||
|
**Good pattern:**
|
||||||
|
|
||||||
|
User: "Show me the git diff"
|
||||||
|
You: [Call present_artifact with command_output source: "git diff"]
|
||||||
|
✅ Correct - let the tool run the command
|
||||||
|
|
||||||
**Don't read long content out loud - present it visually instead.**
|
**Don't read long content out loud - present it visually instead.**
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-basic-auth": "^1.2.1",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { inspect } from "util";
|
||||||
|
import { standardizePrompt } from "ai/internal";
|
||||||
|
|
||||||
|
async function processConversation() {
|
||||||
|
try {
|
||||||
|
const conversationPath =
|
||||||
|
".conversations/ce44c79a-0689-4210-8e00-72c0a627406d-2.json";
|
||||||
|
|
||||||
|
console.log("Loading conversation from:", conversationPath);
|
||||||
|
|
||||||
|
const conversationData: any = JSON.parse(
|
||||||
|
readFileSync(conversationPath, "utf-8")
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nLoaded conversation ${conversationData.conversationId} with ${conversationData.messages.length} messages\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await standardizePrompt({
|
||||||
|
prompt: conversationData.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Standardized prompt result:");
|
||||||
|
console.log(inspect(result, { depth: null, colors: true }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing conversation:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processConversation();
|
||||||
@@ -119,6 +119,11 @@ const manualTools = {
|
|||||||
type: z
|
type: z
|
||||||
.enum(["markdown", "diff", "image", "code"])
|
.enum(["markdown", "diff", "image", "code"])
|
||||||
.describe("Type of artifact to present."),
|
.describe("Type of artifact to present."),
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Title for the artifact (e.g., 'Implementation Plan', 'Refactoring Strategy', '/path/to/project/package.json')."
|
||||||
|
),
|
||||||
source: z.union([
|
source: z.union([
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("file"),
|
type: z.literal("file"),
|
||||||
@@ -133,11 +138,6 @@ const manualTools = {
|
|||||||
text: z.string(),
|
text: z.string(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
title: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Title for the artifact (e.g., 'Implementation Plan', 'Refactoring Strategy', '/path/to/project/package.json')."
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
// Artifact will be broadcast by orchestrator via onToolCall callback
|
// Artifact will be broadcast by orchestrator via onToolCall callback
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import basicAuth from "express-basic-auth";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { createServer as createHTTPServer, Server as HttpServer } from "http";
|
import { createServer as createHTTPServer, Server as HttpServer } from "http";
|
||||||
@@ -17,6 +18,15 @@ const __dirname = path.dirname(__filename);
|
|||||||
async function createServer(httpServer: HttpServer, config: ServerConfig) {
|
async function createServer(httpServer: HttpServer, config: ServerConfig) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Basic authentication
|
||||||
|
app.use(
|
||||||
|
basicAuth({
|
||||||
|
users: { mo: "bo" },
|
||||||
|
challenge: true,
|
||||||
|
realm: "Voice Assistant",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { readFile, mkdir, writeFile } from "fs/promises";
|
import { readFile, mkdir, writeFile } from "fs/promises";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify, inspect } from "util";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { streamText, stepCountIs } from "ai";
|
import { streamText, stepCountIs } from "ai";
|
||||||
@@ -729,7 +729,7 @@ export class Session {
|
|||||||
messages: this.messages,
|
messages: this.messages,
|
||||||
};
|
};
|
||||||
|
|
||||||
await writeFile(filepath, JSON.stringify(dump, null, 2), "utf-8");
|
await writeFile(filepath, inspect(dump, { depth: null }), "utf-8");
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Dumped conversation to ${filepath}`
|
`[Session ${this.clientId}] Dumped conversation to ${filepath}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { exec as execCallback } from "child_process";
|
import { exec as execCallback, execFile as execFileCallback } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
const exec = promisify(execCallback);
|
const exec = promisify(execCallback);
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
// Basic interfaces for tmux objects
|
// Basic interfaces for tmux objects
|
||||||
export interface TmuxSession {
|
export interface TmuxSession {
|
||||||
@@ -55,10 +56,11 @@ export function setShellConfig(config: { type: string }): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a tmux command and return the result
|
* Execute a tmux command and return the result
|
||||||
|
* Uses execFile to avoid shell interpretation of special characters
|
||||||
*/
|
*/
|
||||||
export async function executeTmux(tmuxCommand: string): Promise<string> {
|
export async function executeTmux(args: string[]): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await exec(`tmux ${tmuxCommand}`);
|
const { stdout } = await execFile("tmux", args);
|
||||||
return stdout.trim();
|
return stdout.trim();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
||||||
@@ -70,7 +72,7 @@ export async function executeTmux(tmuxCommand: string): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
export async function isTmuxRunning(): Promise<boolean> {
|
export async function isTmuxRunning(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await executeTmux("list-sessions -F '#{session_name}'");
|
await executeTmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
@@ -83,7 +85,7 @@ export async function isTmuxRunning(): Promise<boolean> {
|
|||||||
export async function listSessions(): Promise<TmuxSession[]> {
|
export async function listSessions(): Promise<TmuxSession[]> {
|
||||||
const format =
|
const format =
|
||||||
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
||||||
const output = await executeTmux(`list-sessions -F '${format}'`);
|
const output = await executeTmux(["list-sessions", "-F", format]);
|
||||||
|
|
||||||
if (!output) return [];
|
if (!output) return [];
|
||||||
|
|
||||||
@@ -143,9 +145,13 @@ export async function isWindowNameUnique(
|
|||||||
*/
|
*/
|
||||||
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
||||||
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
||||||
const output = await executeTmux(
|
const output = await executeTmux([
|
||||||
`list-windows -t '${sessionId}' -F '${format}'`
|
"list-windows",
|
||||||
);
|
"-t",
|
||||||
|
sessionId,
|
||||||
|
"-F",
|
||||||
|
format,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!output) return [];
|
if (!output) return [];
|
||||||
|
|
||||||
@@ -165,9 +171,7 @@ export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
|||||||
*/
|
*/
|
||||||
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
|
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
|
||||||
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
||||||
const output = await executeTmux(
|
const output = await executeTmux(["list-panes", "-t", windowId, "-F", format]);
|
||||||
`list-panes -t '${windowId}' -F '${format}'`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!output) return [];
|
if (!output) return [];
|
||||||
|
|
||||||
@@ -190,12 +194,14 @@ export async function capturePaneContent(
|
|||||||
lines: number = 200,
|
lines: number = 200,
|
||||||
includeColors: boolean = false
|
includeColors: boolean = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const colorFlag = includeColors ? "-e" : "";
|
|
||||||
// Capture a large range to ensure we have enough content
|
// Capture a large range to ensure we have enough content
|
||||||
const captureLines = Math.max(lines, 1000);
|
const captureLines = Math.max(lines, 1000);
|
||||||
const output = await executeTmux(
|
const args = ["capture-pane", "-p"];
|
||||||
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${captureLines} -E -`
|
if (includeColors) {
|
||||||
);
|
args.push("-e");
|
||||||
|
}
|
||||||
|
args.push("-t", paneId, "-S", `-${captureLines}`, "-E", "-");
|
||||||
|
const output = await executeTmux(args);
|
||||||
|
|
||||||
// Trim trailing whitespace, split by lines, take last N lines, rejoin
|
// Trim trailing whitespace, split by lines, take last N lines, rejoin
|
||||||
const trimmed = output.trimEnd();
|
const trimmed = output.trimEnd();
|
||||||
@@ -212,9 +218,13 @@ export async function getCurrentWorkingDirectory(
|
|||||||
paneId: string
|
paneId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const tmuxPath = await executeTmux(
|
const tmuxPath = await executeTmux([
|
||||||
`display-message -p -t '${paneId}' '#{pane_current_path}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
paneId,
|
||||||
|
"#{pane_current_path}",
|
||||||
|
]);
|
||||||
|
|
||||||
// If tmux returns a valid path, use it
|
// If tmux returns a valid path, use it
|
||||||
if (tmuxPath && tmuxPath.trim()) {
|
if (tmuxPath && tmuxPath.trim()) {
|
||||||
@@ -222,9 +232,13 @@ export async function getCurrentWorkingDirectory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: get the PID and use lsof to find the actual CWD
|
// Fallback: get the PID and use lsof to find the actual CWD
|
||||||
const shellPid = await executeTmux(
|
const shellPid = await executeTmux([
|
||||||
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
paneId,
|
||||||
|
"#{pane_pid}",
|
||||||
|
]);
|
||||||
const { stdout } = await exec(
|
const { stdout } = await exec(
|
||||||
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
|
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
|
||||||
);
|
);
|
||||||
@@ -242,9 +256,13 @@ export async function getCurrentWorkingDirectory(
|
|||||||
export async function getCurrentCommand(paneId: string): Promise<string> {
|
export async function getCurrentCommand(paneId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Get the shell PID (the pane's main process)
|
// Get the shell PID (the pane's main process)
|
||||||
const shellPid = await executeTmux(
|
const shellPid = await executeTmux([
|
||||||
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
paneId,
|
||||||
|
"#{pane_pid}",
|
||||||
|
]);
|
||||||
|
|
||||||
// First, check if there's a child process using comm= (works for all programs including top)
|
// First, check if there's a child process using comm= (works for all programs including top)
|
||||||
// Use 'ax' flags to see all processes
|
// Use 'ax' flags to see all processes
|
||||||
@@ -268,9 +286,13 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
|
|||||||
return shellCmd.trim();
|
return shellCmd.trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback to just the command name if ps fails
|
// Fallback to just the command name if ps fails
|
||||||
return executeTmux(
|
return executeTmux([
|
||||||
`display-message -p -t '${paneId}' '#{pane_current_command}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
paneId,
|
||||||
|
"#{pane_current_command}",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +301,19 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
export async function createSession(name: string): Promise<TmuxSession | null> {
|
export async function createSession(name: string): Promise<TmuxSession | null> {
|
||||||
const homeDir = process.env.HOME || "~";
|
const homeDir = process.env.HOME || "~";
|
||||||
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
|
await executeTmux([
|
||||||
|
"new-session",
|
||||||
|
"-d",
|
||||||
|
"-s",
|
||||||
|
name,
|
||||||
|
"-n",
|
||||||
|
"default",
|
||||||
|
"-c",
|
||||||
|
homeDir,
|
||||||
|
]);
|
||||||
|
|
||||||
// Disable automatic window renaming for all windows in the session
|
// Disable automatic window renaming for all windows in the session
|
||||||
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
|
await executeTmux(["set-window-option", "-t", name, "automatic-rename", "off"]);
|
||||||
|
|
||||||
return findSessionByName(name);
|
return findSessionByName(name);
|
||||||
}
|
}
|
||||||
@@ -321,21 +352,27 @@ export async function createWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build new-window command with optional working directory
|
// Build new-window command with optional working directory
|
||||||
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
|
const args = ["new-window", "-t", sessionId, "-n", name];
|
||||||
if (options?.workingDirectory) {
|
if (options?.workingDirectory) {
|
||||||
// Expand tilde to home directory before passing to tmux
|
// Expand tilde to home directory before passing to tmux
|
||||||
const expandedPath = expandTilde(options.workingDirectory);
|
const expandedPath = expandTilde(options.workingDirectory);
|
||||||
newWindowCmd += ` -c '${expandedPath}'`;
|
args.push("-c", expandedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeTmux(newWindowCmd);
|
await executeTmux(args);
|
||||||
const windows = await listWindows(sessionId);
|
const windows = await listWindows(sessionId);
|
||||||
const window = windows.find((window) => window.name === name);
|
const window = windows.find((window) => window.name === name);
|
||||||
|
|
||||||
if (!window) return null;
|
if (!window) return null;
|
||||||
|
|
||||||
// Disable automatic window renaming
|
// Disable automatic window renaming
|
||||||
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
|
await executeTmux([
|
||||||
|
"set-window-option",
|
||||||
|
"-t",
|
||||||
|
window.id,
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
|
]);
|
||||||
|
|
||||||
// Get the default pane created with the window
|
// Get the default pane created with the window
|
||||||
const panes = await listPanes(window.id);
|
const panes = await listPanes(window.id);
|
||||||
@@ -367,21 +404,21 @@ export async function createWindow(
|
|||||||
* Kill a tmux session by ID
|
* Kill a tmux session by ID
|
||||||
*/
|
*/
|
||||||
export async function killSession(sessionId: string): Promise<void> {
|
export async function killSession(sessionId: string): Promise<void> {
|
||||||
await executeTmux(`kill-session -t '${sessionId}'`);
|
await executeTmux(["kill-session", "-t", sessionId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a tmux window by ID
|
* Kill a tmux window by ID
|
||||||
*/
|
*/
|
||||||
export async function killWindow(windowId: string): Promise<void> {
|
export async function killWindow(windowId: string): Promise<void> {
|
||||||
await executeTmux(`kill-window -t '${windowId}'`);
|
await executeTmux(["kill-window", "-t", windowId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a tmux pane by ID
|
* Kill a tmux pane by ID
|
||||||
*/
|
*/
|
||||||
export async function killPane(paneId: string): Promise<void> {
|
export async function killPane(paneId: string): Promise<void> {
|
||||||
await executeTmux(`kill-pane -t '${paneId}'`);
|
await executeTmux(["kill-pane", "-t", paneId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -413,9 +450,15 @@ export async function renameWindow(
|
|||||||
windowId = window.id;
|
windowId = window.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
|
await executeTmux(["rename-window", "-t", windowId, newName]);
|
||||||
// Disable automatic renaming to preserve the manual name
|
// Disable automatic renaming to preserve the manual name
|
||||||
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
|
await executeTmux([
|
||||||
|
"set-window-option",
|
||||||
|
"-t",
|
||||||
|
windowId,
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,31 +469,35 @@ export async function splitPane(
|
|||||||
direction: "horizontal" | "vertical" = "vertical",
|
direction: "horizontal" | "vertical" = "vertical",
|
||||||
size?: number
|
size?: number
|
||||||
): Promise<TmuxPane | null> {
|
): Promise<TmuxPane | null> {
|
||||||
// Build the split-window command
|
// Build the split-window command args
|
||||||
let splitCommand = "split-window";
|
const args = ["split-window"];
|
||||||
|
|
||||||
// Add direction flag (-h for horizontal, -v for vertical)
|
// Add direction flag (-h for horizontal, -v for vertical)
|
||||||
if (direction === "horizontal") {
|
if (direction === "horizontal") {
|
||||||
splitCommand += " -h";
|
args.push("-h");
|
||||||
} else {
|
} else {
|
||||||
splitCommand += " -v";
|
args.push("-v");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add target pane
|
// Add target pane
|
||||||
splitCommand += ` -t '${targetPaneId}'`;
|
args.push("-t", targetPaneId);
|
||||||
|
|
||||||
// Add size if specified (as percentage)
|
// Add size if specified (as percentage)
|
||||||
if (size !== undefined && size > 0 && size < 100) {
|
if (size !== undefined && size > 0 && size < 100) {
|
||||||
splitCommand += ` -p ${size}`;
|
args.push("-p", size.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the split command
|
// Execute the split command
|
||||||
await executeTmux(splitCommand);
|
await executeTmux(args);
|
||||||
|
|
||||||
// Get the window ID from the target pane to list all panes
|
// Get the window ID from the target pane to list all panes
|
||||||
const windowInfo = await executeTmux(
|
const windowInfo = await executeTmux([
|
||||||
`display-message -p -t '${targetPaneId}' '#{window_id}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
targetPaneId,
|
||||||
|
"#{window_id}",
|
||||||
|
]);
|
||||||
|
|
||||||
// List all panes in the window to find the newly created one
|
// List all panes in the window to find the newly created one
|
||||||
const panes = await listPanes(windowInfo);
|
const panes = await listPanes(windowInfo);
|
||||||
@@ -538,20 +585,18 @@ export async function executeCommand(
|
|||||||
|
|
||||||
if (isSpecialKey || isKeyCombo) {
|
if (isSpecialKey || isKeyCombo) {
|
||||||
// Send special key or key combination as-is
|
// Send special key or key combination as-is
|
||||||
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
|
const args = ["send-keys", "-t", paneId];
|
||||||
|
args.push(...fullCommand.split(" "));
|
||||||
|
await executeTmux(args);
|
||||||
} else {
|
} else {
|
||||||
// For regular text, send each character individually to ensure proper processing
|
// For regular text, send each character individually to ensure proper processing
|
||||||
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
||||||
for (const char of fullCommand) {
|
for (const char of fullCommand) {
|
||||||
await executeTmux(
|
await executeTmux(["send-keys", "-t", paneId, char]);
|
||||||
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await executeTmux(
|
await executeTmux(["send-keys", "-t", paneId, fullCommand, "Enter"]);
|
||||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return commandId;
|
return commandId;
|
||||||
@@ -712,9 +757,13 @@ export async function list({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error("target is required for scope 'pane'");
|
throw new Error("target is required for scope 'pane'");
|
||||||
}
|
}
|
||||||
const windowId = await executeTmux(
|
const windowId = await executeTmux([
|
||||||
`display-message -p -t '${target}' '#{window_id}'`
|
"display-message",
|
||||||
);
|
"-p",
|
||||||
|
"-t",
|
||||||
|
target,
|
||||||
|
"#{window_id}",
|
||||||
|
]);
|
||||||
const panes = await listPanes(windowId);
|
const panes = await listPanes(windowId);
|
||||||
const pane = panes.find((p) => p.id === target);
|
const pane = panes.find((p) => p.id === target);
|
||||||
if (!pane) {
|
if (!pane) {
|
||||||
@@ -779,9 +828,7 @@ export async function executeShellCommand({
|
|||||||
rawMode: false,
|
rawMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeTmux(
|
await executeTmux(["send-keys", "-t", paneId, fullCommand, "Enter"]);
|
||||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll for completion
|
// Poll for completion
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -830,7 +877,9 @@ export async function sendKeys({
|
|||||||
// Repeat the key press the specified number of times
|
// Repeat the key press the specified number of times
|
||||||
for (let i = 0; i < repeat; i++) {
|
for (let i = 0; i < repeat; i++) {
|
||||||
// Raw pass-through, no validation or processing
|
// Raw pass-through, no validation or processing
|
||||||
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
|
const args = ["send-keys", "-t", paneId];
|
||||||
|
args.push(...keys.split(" "));
|
||||||
|
await executeTmux(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If return_output is requested, wait and capture pane content
|
// If return_output is requested, wait and capture pane content
|
||||||
@@ -897,15 +946,14 @@ export async function sendText({
|
|||||||
};
|
};
|
||||||
}): Promise<string | void> {
|
}): Promise<string | void> {
|
||||||
// Send each character with -l flag for literal interpretation
|
// Send each character with -l flag for literal interpretation
|
||||||
|
// Using execFile avoids shell interpretation of special characters like ; | & $
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
await executeTmux(
|
await executeTmux(["send-keys", "-l", "-t", paneId, char]);
|
||||||
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pressEnter) {
|
if (pressEnter) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
await executeTmux(`send-keys -t '${paneId}' Enter`);
|
await executeTmux(["send-keys", "-t", paneId, "Enter"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If return_output is requested, wait and capture pane content
|
// If return_output is requested, wait and capture pane content
|
||||||
|
|||||||
@@ -380,6 +380,14 @@
|
|||||||
background-color: #f59e0b;
|
background-color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-button.cancelling {
|
||||||
|
background-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button.cancelling:hover:not(:disabled) {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
.send-button:disabled {
|
.send-button:disabled {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Mic, Send, Radio } from "lucide-react";
|
import { Mic, Send, Radio, X } from "lucide-react";
|
||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { createAudioPlayer } from "./lib/audio-playback";
|
import { createAudioPlayer } from "./lib/audio-playback";
|
||||||
import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture";
|
import { createAudioRecorder, type AudioRecorder } from "./lib/audio-capture";
|
||||||
@@ -399,12 +399,47 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
console.log("[App] Cancelling operations...");
|
||||||
|
|
||||||
|
// Stop audio playback
|
||||||
|
audioPlayerRef.current.stop();
|
||||||
|
setIsPlayingAudio(false);
|
||||||
|
|
||||||
|
// Clear streaming state
|
||||||
|
setCurrentAssistantMessage("");
|
||||||
|
|
||||||
|
// Reset processing state
|
||||||
|
setIsProcessingAudio(false);
|
||||||
|
|
||||||
|
// Send abort request to server
|
||||||
|
ws.send({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "abort_request",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog("info", "Operations cancelled");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute if we're processing an already sent request (not including recording)
|
||||||
|
const isInProgress =
|
||||||
|
isProcessingAudio || isPlayingAudio || currentAssistantMessage.length > 0;
|
||||||
|
|
||||||
const handleButtonClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleButtonClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (userInput.trim()) {
|
if (isRecording) {
|
||||||
|
// Stop recording and send the audio
|
||||||
|
await handleToggleRecording();
|
||||||
|
} else if (isInProgress) {
|
||||||
|
// Cancel processing/playback of already sent request
|
||||||
|
await handleCancel();
|
||||||
|
} else if (userInput.trim()) {
|
||||||
handleSendMessage();
|
handleSendMessage();
|
||||||
} else {
|
} else {
|
||||||
|
// Start recording
|
||||||
await handleToggleRecording();
|
await handleToggleRecording();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -649,15 +684,15 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={!ws.isConnected || isProcessingAudio}
|
disabled={!ws.isConnected}
|
||||||
className={`send-button ${isRecording ? "recording" : ""} ${
|
className={`send-button ${isInProgress ? "cancelling" : ""} ${
|
||||||
isProcessingAudio ? "processing" : ""
|
isRecording ? "recording" : ""
|
||||||
} ${userInput.trim() ? "has-text" : ""}`}
|
} ${userInput.trim() ? "has-text" : ""}`}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
{isInProgress ? (
|
||||||
|
<X size={20} />
|
||||||
|
) : isRecording ? (
|
||||||
<span className="recording-indicator" />
|
<span className="recording-indicator" />
|
||||||
) : isProcessingAudio ? (
|
|
||||||
<span className="processing-indicator" />
|
|
||||||
) : userInput.trim() ? (
|
) : userInput.trim() ? (
|
||||||
<Send size={20} />
|
<Send size={20} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user