Skip to content

Commit eaaac69

Browse files
authored
feat: Add web terminal interface with browser-based terminal access (#351)
Adds a web terminal interface that provides browser-based access to the CLI chat interface. Users can now run `infer chat --web` to start a web server and access the terminal from any browser with tabbed sessions. Key features: - Browser-based terminal using xterm.js with Tokyo Night theme - Multiple independent tabbed sessions with isolated containers - Automatic session cleanup after configurable inactivity period - Remote access capability with security considerations documented - Graceful shutdown handling for all sessions and containers The implementation includes PTY management, WebSocket communication, session tracking, and comprehensive documentation.
1 parent 68d462b commit eaaac69

File tree

18 files changed

+2089
-16
lines changed

18 files changed

+2089
-16
lines changed

.infer/config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,8 @@ compact:
620620
enabled: true
621621
auto_at: 80
622622
keep_first_messages: 2
623+
web:
624+
enabled: false
625+
port: 3000
626+
host: localhost
627+
session_inactivity_mins: 5

.infer/mcp.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ servers:
1313
host: localhost
1414
scheme: http
1515
ports:
16-
- "3000:8010"
16+
- "8010:8010"
1717
path: /mcp
1818
oci: mekayelanik/context7-mcp:stable
1919
startup_timeout: 90

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ and management of inference services.
6868
- **Extensible Shortcuts System**: Create custom commands with AI-powered snippets - [Learn more →](docs/shortcuts-guide.md)
6969
- **MCP Server Support**: Direct integration with Model Context Protocol servers for extended tool capabilities -
7070
[Learn more →](docs/mcp-integration.md)
71+
- **Web Terminal Interface**: Browser-based terminal access with tabbed sessions for remote access and multi-session workflows - [Learn more →](docs/web-terminal.md)
7172

7273
## Installation
7374

@@ -180,6 +181,7 @@ Now that you're up and running, explore these guides:
180181
- **[Commands Reference](docs/commands-reference.md)** - Complete command documentation
181182
- **[Tools Reference](docs/tools-reference.md)** - Available tools for LLMs
182183
- **[Configuration Guide](docs/configuration-reference.md)** - Full configuration options
184+
- **[Web Terminal](docs/web-terminal.md)** - Browser-based terminal interface
183185
- **[Shortcuts Guide](docs/shortcuts-guide.md)** - Custom shortcuts and AI-powered snippets
184186
- **[A2A Agents](docs/agents-configuration.md)** - Agent-to-agent communication setup
185187

@@ -199,11 +201,25 @@ infer init --userspace # Initialize user-level configuration
199201
**`infer chat`** - Start an interactive chat session with model selection
200202

201203
```bash
204+
# Terminal mode (default)
202205
infer chat
206+
207+
# Web terminal mode with browser interface
208+
infer chat --web
209+
infer chat --web --port 8080 # Custom port
203210
```
204211

205212
**Features:** Model selection, real-time streaming, scrollable history, three agent modes (Standard/Plan/Auto-Accept).
206213

214+
**Web Mode Features:**
215+
216+
- Browser-based terminal using xterm.js
217+
- Multiple independent tabbed sessions
218+
- Automatic session cleanup on inactivity
219+
- Each tab manages its own `infer chat` process with isolated containers
220+
- Access from any device on the network
221+
- Responsive terminal sizing with horizontal padding
222+
207223
**`infer agent`** - Execute autonomous tasks in background mode
208224

209225
```bash
@@ -383,6 +399,10 @@ infer chat --model "anthropic/claude-4"
383399
- **chat.theme** - Chat interface theme (default: `tokyo-night`)
384400
- **chat.status_bar.enabled** - Enable/disable status bar (default: `true`)
385401
- **chat.status_bar.indicators** - Configure individual status indicators (all enabled by default except `max_output`)
402+
- **web.enabled** - Enable web terminal mode (default: `false`)
403+
- **web.port** - Web server port (default: `3000`)
404+
- **web.host** - Web server host (default: `localhost`)
405+
- **web.session_inactivity_mins** - Session timeout in minutes (default: `5`)
386406

387407
### Environment Variables
388408

@@ -393,6 +413,11 @@ export INFER_GATEWAY_URL="http://localhost:8080"
393413
export INFER_AGENT_MODEL="deepseek/deepseek-chat"
394414
export INFER_TOOLS_BASH_ENABLED=true
395415
export INFER_CHAT_THEME="tokyo-night"
416+
417+
# Web terminal configuration
418+
export INFER_WEB_PORT=3000
419+
export INFER_WEB_HOST="localhost"
420+
export INFER_WEB_SESSION_INACTIVITY_MINS=5
396421
```
397422

398423
**Format:** `INFER_<PATH>` where dots become underscores.
@@ -714,6 +739,38 @@ infer config tools web-search enable
714739
infer config show
715740
```
716741

742+
### Web Terminal Example
743+
744+
```bash
745+
# Start web terminal server
746+
infer chat --web
747+
748+
# Open browser to http://localhost:3000
749+
# Click "+" to create new terminal tabs
750+
# Each tab is an independent chat session
751+
752+
# Custom port for remote access
753+
infer chat --web --port 8080 --host 0.0.0.0
754+
755+
# Configure via config file
756+
cat > .infer/config.yaml <<EOF
757+
web:
758+
enabled: true
759+
port: 3000
760+
host: "localhost"
761+
session_inactivity_mins: 10 # Auto-cleanup after 10 minutes
762+
EOF
763+
764+
infer chat --web # Uses config file settings
765+
```
766+
767+
**Use Cases:**
768+
769+
- Remote access to CLI from any device
770+
- Multiple parallel chat sessions in browser tabs
771+
- Team collaboration with shared terminal access
772+
- Persistent sessions with automatic cleanup
773+
717774
## Development
718775

719776
For development, use [Task](https://taskfile.dev) for build automation:

cmd/chat.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,46 @@ import (
55
"context"
66
"fmt"
77
"os"
8+
"os/signal"
89
"path/filepath"
910
"strings"
11+
"sync"
12+
"syscall"
1013
"time"
1114

1215
tea "github.com/charmbracelet/bubbletea"
16+
cobra "github.com/spf13/cobra"
17+
viper "github.com/spf13/viper"
18+
1319
config "github.com/inference-gateway/cli/config"
1420
app "github.com/inference-gateway/cli/internal/app"
1521
clipboard "github.com/inference-gateway/cli/internal/clipboard"
1622
container "github.com/inference-gateway/cli/internal/container"
1723
domain "github.com/inference-gateway/cli/internal/domain"
24+
logger "github.com/inference-gateway/cli/internal/logger"
25+
web "github.com/inference-gateway/cli/internal/web"
1826
sdk "github.com/inference-gateway/sdk"
19-
cobra "github.com/spf13/cobra"
20-
viper "github.com/spf13/viper"
2127
)
2228

2329
var chatCmd = &cobra.Command{
2430
Use: "chat",
2531
Short: "Start an interactive chat session with model selection",
2632
Long: `Start an interactive chat session where you can select a model from a dropdown
2733
and have a conversational interface with the inference gateway.`,
28-
RunE: func(_ *cobra.Command, args []string) error {
34+
RunE: func(cmd *cobra.Command, args []string) error {
2935
cfg, err := getConfigFromViper()
3036
if err != nil {
3137
return fmt.Errorf("failed to load config: %w", err)
3238
}
3339

40+
webMode, _ := cmd.Flags().GetBool("web")
41+
if webMode {
42+
if cmd.Flags().Changed("port") {
43+
cfg.Web.Port, _ = cmd.Flags().GetInt("port")
44+
}
45+
return StartWebChatSession(cfg, V)
46+
}
47+
3448
if !isInteractiveTerminal() {
3549
return runNonInteractiveChat(cfg, V)
3650
}
@@ -44,10 +58,31 @@ func StartChatSession(cfg *config.Config, v *viper.Viper) error {
4458
_ = clipboard.Init()
4559

4660
services := container.NewServiceContainer(cfg, v)
47-
defer func() {
48-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
49-
defer cancel()
50-
_ = services.Shutdown(ctx)
61+
62+
sigChan := make(chan os.Signal, 1)
63+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
64+
65+
shutdownComplete := make(chan struct{})
66+
var shutdownOnce sync.Once
67+
68+
doShutdown := func() {
69+
shutdownOnce.Do(func() {
70+
logger.Info("Received shutdown signal, cleaning up...")
71+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
72+
defer cancel()
73+
if err := services.Shutdown(ctx); err != nil {
74+
logger.Error("Error during shutdown", "error", err)
75+
}
76+
close(shutdownComplete)
77+
})
78+
}
79+
80+
defer doShutdown()
81+
82+
go func() {
83+
<-sigChan
84+
doShutdown()
85+
os.Exit(0)
5186
}()
5287

5388
if err := services.GetGatewayManager().EnsureStarted(); err != nil {
@@ -134,6 +169,12 @@ func StartChatSession(cfg *config.Config, v *viper.Viper) error {
134169
return nil
135170
}
136171

172+
// StartWebChatSession starts a web-based chat session with PTY and WebSocket
173+
func StartWebChatSession(cfg *config.Config, v *viper.Viper) error {
174+
server := web.NewWebTerminalServer(cfg, v)
175+
return server.Start()
176+
}
177+
137178
func validateAndSetDefaultModel(modelService domain.ModelService, models []string, defaultModel string) string {
138179
modelFound := false
139180
for _, model := range models {
@@ -197,7 +238,7 @@ func isInteractiveTerminal() bool {
197238
func runNonInteractiveChat(cfg *config.Config, v *viper.Viper) error {
198239
services := container.NewServiceContainer(cfg, v)
199240
defer func() {
200-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
241+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
201242
defer cancel()
202243
_ = services.Shutdown(ctx)
203244
}()
@@ -302,4 +343,6 @@ func processStreamingOutput(events <-chan domain.ChatEvent) error {
302343

303344
func init() {
304345
rootCmd.AddCommand(chatCmd)
346+
chatCmd.Flags().Bool("web", false, "Start web terminal interface")
347+
chatCmd.Flags().Int("port", 0, "Web server port (default: 3000)")
305348
}

cmd/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ func initConfig() { // nolint:funlen
7979
v.SetDefault("compact.enabled", defaults.Compact.Enabled)
8080
v.SetDefault("compact.auto_at", defaults.Compact.AutoAt)
8181
v.SetDefault("compact.keep_first_messages", defaults.Compact.KeepFirstMessages)
82+
v.SetDefault("web", defaults.Web)
83+
v.SetDefault("web.enabled", defaults.Web.Enabled)
84+
v.SetDefault("web.port", defaults.Web.Port)
85+
v.SetDefault("web.host", defaults.Web.Host)
86+
v.SetDefault("web.session_inactivity_mins", defaults.Web.SessionInactivityMins)
8287
v.SetDefault("git", defaults.Git)
8388
v.SetDefault("storage", defaults.Storage)
8489
v.SetDefault("conversation", defaults.Conversation)

config/config.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"regexp"
1010
"strings"
11+
"sync"
1112
)
1213

1314
const (
@@ -40,6 +41,7 @@ type Config struct {
4041
Pricing PricingConfig `yaml:"pricing" mapstructure:"pricing"`
4142
Init InitConfig `yaml:"init" mapstructure:"init"`
4243
Compact CompactConfig `yaml:"compact" mapstructure:"compact"`
44+
Web WebConfig `yaml:"web" mapstructure:"web"`
4345
configDir string
4446
}
4547

@@ -263,6 +265,14 @@ type CompactConfig struct {
263265
KeepFirstMessages int `yaml:"keep_first_messages" mapstructure:"keep_first_messages"`
264266
}
265267

268+
// WebConfig contains web terminal settings
269+
type WebConfig struct {
270+
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
271+
Port int `yaml:"port" mapstructure:"port"`
272+
Host string `yaml:"host" mapstructure:"host"`
273+
SessionInactivityMins int `yaml:"session_inactivity_mins" mapstructure:"session_inactivity_mins"`
274+
}
275+
266276
// SystemRemindersConfig contains settings for dynamic system reminders
267277
type SystemRemindersConfig struct {
268278
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
@@ -921,6 +931,12 @@ Write the AGENTS.md file to the project root when you have gathered enough infor
921931
AutoAt: 80,
922932
KeepFirstMessages: 2,
923933
},
934+
Web: WebConfig{
935+
Enabled: false,
936+
Port: 3000,
937+
Host: "localhost",
938+
SessionInactivityMins: 5,
939+
},
924940
}
925941
}
926942

@@ -1242,18 +1258,42 @@ func ActionID(namespace KeyNamespace, action string) string {
12421258
return string(namespace) + "_" + action
12431259
}
12441260

1261+
// Global port registry to prevent race conditions when allocating ports
1262+
var (
1263+
allocatedPorts = make(map[int]bool)
1264+
portMutex sync.Mutex
1265+
)
1266+
12451267
// FindAvailablePort finds the next available port starting from basePort
12461268
// It checks up to 100 ports after the base port
12471269
// Binds to all interfaces (0.0.0.0) to match Docker's behavior
1270+
// Thread-safe: uses global port registry to prevent race conditions
12481271
func FindAvailablePort(basePort int) int {
1272+
portMutex.Lock()
1273+
defer portMutex.Unlock()
1274+
12491275
for port := basePort; port < basePort+100; port++ {
1276+
if allocatedPorts[port] {
1277+
continue
1278+
}
1279+
12501280
address := fmt.Sprintf(":%d", port)
12511281
listener, err := net.Listen("tcp", address)
12521282
if err != nil {
12531283
continue
12541284
}
12551285
_ = listener.Close()
1286+
1287+
allocatedPorts[port] = true
12561288
return port
12571289
}
12581290
return basePort
12591291
}
1292+
1293+
// ReleasePort releases a previously allocated port
1294+
// Should be called when containers are stopped
1295+
func ReleasePort(port int) {
1296+
portMutex.Lock()
1297+
defer portMutex.Unlock()
1298+
delete(allocatedPorts, port)
1299+
}

0 commit comments

Comments
 (0)