Skip to content
Open

Tmux #131

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AGENTAPI_ALLOWED_HOSTS="dev.otherstuff.studio dev.otherstuff.ai localhost"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This file provides guidance to AI agents working with code in this repository.
- `agentapi server -- goose` - Start server with Goose agent
- `agentapi server --type=codex -- codex` - Start server with Codex (requires explicit type)
- `agentapi server --type=gemini -- gemini` - Start server with Gemini (requires explicit type)
- `agentapi server --tmux-session=my-app -- claude` - Override the tmux session name (defaults to `wingman-agents`)
- `agentapi attach --url localhost:3284` - Attach to running agent terminal
- Server runs on port 3284 by default
- Chat UI available at http://localhost:3284/chat
Expand Down
115 changes: 96 additions & 19 deletions chat/src/components/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
Command as CommandIcon,
CornerDownLeftIcon,
DeleteIcon,
SendIcon,
Upload,
Square,
} from "lucide-react";
import {Tabs, TabsList, TabsTrigger} from "./ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import type {ServerStatus} from "./chat-provider";
import TextareaAutosize from "react-textarea-autosize";
import {useChat} from "./chat-provider";
Expand Down Expand Up @@ -50,6 +57,32 @@ const specialKeys: Record<string, string> = {
Backspace: "\b", // Backspace key
};

const SHIFT_TAB_SEQUENCE = "\x1b[Z";
const CLEAR_SCREEN_SEQUENCE = "\x0C";

interface ControlAction {
id: string;
label: string;
display: string;
sequence: string;
}

const CONTROL_ACTIONS: ControlAction[] = [
{id: "escape", label: "Esc", display: "Esc", sequence: specialKeys.Escape},
{id: "enter", label: "Enter", display: "⏎", sequence: "\r"},
{id: "tab", label: "Tab", display: "Tab", sequence: specialKeys.Tab},
{id: "shift-tab", label: "Shift+Tab", display: "Shift+Tab", sequence: SHIFT_TAB_SEQUENCE},
{id: "one-enter", label: "1 + Enter", display: "1⏎", sequence: "1\r"},
{id: "two-enter", label: "2 + Enter", display: "2⏎", sequence: "2\r"},
{id: "three-enter", label: "3 + Enter", display: "3⏎", sequence: "3\r"},
{id: "ctrl-c", label: "Ctrl+C", display: "Ctrl+C", sequence: "\x03"},
{id: "arrow-up", label: "Up", display: "ArrowUp", sequence: specialKeys.ArrowUp},
{id: "arrow-down", label: "Down", display: "ArrowDown", sequence: specialKeys.ArrowDown},
{id: "arrow-left", label: "Left", display: "ArrowLeft", sequence: specialKeys.ArrowLeft},
{id: "arrow-right", label: "Right", display: "ArrowRight", sequence: specialKeys.ArrowRight},
{id: "clear", label: "Clear", display: "Clear", sequence: CLEAR_SCREEN_SEQUENCE},
];

export default function MessageInput({
onSendMessage,
disabled = false,
Expand Down Expand Up @@ -121,9 +154,25 @@ export default function MessageInput({
setSentChars((prev) => [...prev, newChar]);
};

const handleControlAction = (action: ControlAction) => {
if (disabled || inputMode !== "control") {
return;
}
addSentChar(action.display);
onSendMessage(action.sequence, "raw");
textareaRef.current?.focus();
};

const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// In control mode, send special keys as raw messages
if (inputMode === "control" && !disabled) {
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
addSentChar("Shift+Tab");
onSendMessage(SHIFT_TAB_SEQUENCE, "raw");
return;
}

// Check if the pressed key is in our special keys map
if (specialKeys[e.key]) {
e.preventDefault();
Expand Down Expand Up @@ -241,26 +290,54 @@ export default function MessageInput({
</div>

<div className="flex items-center justify-between p-4">
<TabsList className="bg-transparent">
<TabsTrigger
value="text"
onClick={() => {
textareaRef.current?.focus();
}}
>
Text
</TabsTrigger>
<TabsTrigger
value="control"
onClick={() => {
textareaRef.current?.focus();
}}
>
Control
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<TabsList className="bg-transparent">
<TabsTrigger
value="text"
onClick={() => {
textareaRef.current?.focus();
}}
>
Text
</TabsTrigger>
<TabsTrigger
value="control"
onClick={() => {
textareaRef.current?.focus();
}}
>
Control
</TabsTrigger>
</TabsList>

{inputMode === "control" && !disabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
>
<CommandIcon className="h-4 w-4"/>
Cmd Menu
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{CONTROL_ACTIONS.map((action) => (
<DropdownMenuItem
key={action.id}
onSelect={() => handleControlAction(action)}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

<div className={"flex flex-row gap-3"}>
<div className="flex flex-row items-center gap-3">
<Button
type="submit"
size="icon"
Expand Down
113 changes: 102 additions & 11 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import (
"log/slog"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
"unicode"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -80,6 +85,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er

termWidth := viper.GetUint16(FlagTermWidth)
termHeight := viper.GetUint16(FlagTermHeight)
tmuxSession := viper.GetString(FlagTmuxSession)

if termWidth < 10 {
return xerrors.Errorf("term width must be at least 10")
Expand All @@ -99,18 +105,25 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
TerminalWidth: termWidth,
TerminalHeight: termHeight,
AgentType: agentType,
SessionName: tmuxSession,
})
if err != nil {
return xerrors.Errorf("failed to setup process: %w", err)
}
}
allowedHosts := viper.GetStringSlice(FlagAllowedHosts)
if envHosts := parseEnvList(os.Getenv("ALLOW_HOSTS")); len(envHosts) > 0 {
logger.Info("Using allowed hosts from ALLOW_HOSTS env", "hosts", strings.Join(envHosts, ", "))
allowedHosts = envHosts
}

port := viper.GetInt(FlagPort)
srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
AgentType: agentType,
Process: process,
Port: port,
ChatBasePath: viper.GetString(FlagChatBasePath),
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
AllowedHosts: allowedHosts,
AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins),
InitialPrompt: viper.GetString(FlagInitialPrompt),
})
Expand All @@ -123,6 +136,22 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}
srv.StartSnapshotLoop(ctx)
logger.Info("Starting server on port", "port", port)
stopOnce := &sync.Once{}
stopServer := func(reason string) {
stopOnce.Do(func() {
logger.Info("Stopping server", "reason", reason)
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer stopCancel()
if err := srv.Stop(stopCtx); err != nil && err != context.Canceled && err != http.ErrServerClosed {
logger.Error("Failed to stop server", "error", err)
}
})
}

signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(signalCh)

processExitCh := make(chan error, 1)
go func() {
defer close(processExitCh)
Expand All @@ -133,19 +162,79 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
}
}
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
}
}()
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {
return xerrors.Errorf("failed to start server: %w", err)

serverErrCh := make(chan error, 1)
go func() {
err := srv.Start()
serverErrCh <- err
close(serverErrCh)
}()

var (
processErr error
processDone bool
receivedSignal bool
)

for {
select {
case sig := <-signalCh:
if receivedSignal {
logger.Warn("Second signal received, forcing exit", "signal", sig)
os.Exit(1)
}
receivedSignal = true
logger.Info("Signal received, shutting down", "signal", sig)
if err := process.Close(logger, 5*time.Second); err != nil {
logger.Error("Error closing process", "error", err)
}
stopServer("signal")
case err, ok := <-processExitCh:
if !ok {
processExitCh = nil
continue
}
processDone = true
processErr = err
stopServer("process exited")
processExitCh = nil
case err, ok := <-serverErrCh:
if !ok {
serverErrCh = nil
continue
}
if err != nil && err != http.ErrServerClosed && err != context.Canceled {
stopServer("server error")
return xerrors.Errorf("failed to start server: %w", err)
}
serverErrCh = nil
}

if serverErrCh == nil && processExitCh == nil {
if processDone && processErr != nil {
return xerrors.Errorf("agent exited with error: %w", processErr)
}
return nil
}
}
select {
case err := <-processExitCh:
return xerrors.Errorf("agent exited with error: %w", err)
default:
}

func parseEnvList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || unicode.IsSpace(r)
})
out := make([]string, 0, len(fields))
for _, item := range fields {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
out = append(out, trimmed)
}
}
return nil
return out
}

var agentNames = (func() []string {
Expand All @@ -172,6 +261,7 @@ const (
FlagChatBasePath = "chat-base-path"
FlagTermWidth = "term-width"
FlagTermHeight = "term-height"
FlagTmuxSession = "tmux-session"
FlagAllowedHosts = "allowed-hosts"
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
Expand Down Expand Up @@ -209,6 +299,7 @@ func CreateServerCmd() *cobra.Command {
{FlagChatBasePath, "c", "/chat", "Base path for assets and routes used in the static files of the chat interface", "string"},
{FlagTermWidth, "W", uint16(80), "Width of the emulated terminal", "uint16"},
{FlagTermHeight, "H", uint16(1000), "Height of the emulated terminal", "uint16"},
{FlagTmuxSession, "", termexec.DefaultTmuxSessionName, "Name of the tmux session used to host agent processes", "string"},
// localhost is the default host for the server. Port is ignored during matching.
{FlagAllowedHosts, "a", []string{"localhost", "127.0.0.1", "[::1]"}, "HTTP allowed hosts (hostnames only, no ports). Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_HOSTS env var", "stringSlice"},
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
Expand Down
11 changes: 11 additions & 0 deletions cmd/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/agentapi/lib/termexec"
)

type nullWriter struct{}
Expand Down Expand Up @@ -230,6 +232,7 @@ func TestServerCmd_AllArgs_Defaults(t *testing.T) {
{"chat-base-path default", FlagChatBasePath, "/chat", func() any { return viper.GetString(FlagChatBasePath) }},
{"term-width default", FlagTermWidth, uint16(80), func() any { return viper.GetUint16(FlagTermWidth) }},
{"term-height default", FlagTermHeight, uint16(1000), func() any { return viper.GetUint16(FlagTermHeight) }},
{"tmux-session default", FlagTmuxSession, termexec.DefaultTmuxSessionName, func() any { return viper.GetString(FlagTmuxSession) }},
{"allowed-hosts default", FlagAllowedHosts, []string{"localhost", "127.0.0.1", "[::1]"}, func() any { return viper.GetStringSlice(FlagAllowedHosts) }},
{"allowed-origins default", FlagAllowedOrigins, []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, func() any { return viper.GetStringSlice(FlagAllowedOrigins) }},
}
Expand Down Expand Up @@ -264,6 +267,7 @@ func TestServerCmd_AllEnvVars(t *testing.T) {
{"AGENTAPI_CHAT_BASE_PATH", "AGENTAPI_CHAT_BASE_PATH", "/api", "/api", func() any { return viper.GetString(FlagChatBasePath) }},
{"AGENTAPI_TERM_WIDTH", "AGENTAPI_TERM_WIDTH", "120", uint16(120), func() any { return viper.GetUint16(FlagTermWidth) }},
{"AGENTAPI_TERM_HEIGHT", "AGENTAPI_TERM_HEIGHT", "500", uint16(500), func() any { return viper.GetUint16(FlagTermHeight) }},
{"AGENTAPI_TMUX_SESSION", "AGENTAPI_TMUX_SESSION", "custom-session", "custom-session", func() any { return viper.GetString(FlagTmuxSession) }},
{"AGENTAPI_ALLOWED_HOSTS", "AGENTAPI_ALLOWED_HOSTS", "localhost example.com", []string{"localhost", "example.com"}, func() any { return viper.GetStringSlice(FlagAllowedHosts) }},
{"AGENTAPI_ALLOWED_ORIGINS", "AGENTAPI_ALLOWED_ORIGINS", "https://example.com http://localhost:3000", []string{"https://example.com", "http://localhost:3000"}, func() any { return viper.GetStringSlice(FlagAllowedOrigins) }},
}
Expand Down Expand Up @@ -336,6 +340,13 @@ func TestServerCmd_ArgsPrecedenceOverEnv(t *testing.T) {
uint16(600),
func() any { return viper.GetUint16(FlagTermHeight) },
},
{
"tmux-session: CLI overrides env",
"AGENTAPI_TMUX_SESSION", "session-env",
[]string{"--tmux-session", "session-cli"},
"session-cli",
func() any { return viper.GetString(FlagTmuxSession) },
},
{
"allowed-origins: CLI overrides env",
"AGENTAPI_ALLOWED_ORIGINS", "https://env-example.com http://localhost:3000",
Expand Down
Loading