Skip to content

Commit f1b9a35

Browse files
engalarclaude
andcommitted
fix(tui): run TTY commands directly, bypassing daemon socket
tui, serve, oql and playwright need a real terminal: stdin for keyboard input, stdout for escape-code rendering, and terminal size signals. When forwarded through the launcher→daemon Unix socket, stdin is disconnected and stdout is JSON-framed, which breaks interactive display entirely. Detect these commands in the launcher before socket routing and exec the daemon binary directly with inherited stdin/stdout/stderr. Both argument orderings work: "tui -p file.mpr" and "-p file.mpr tui". flagsWithValue tracks root-level flags whose next token is a value (not a subcommand) so that -p <path> is correctly skipped during detection. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f5495f commit f1b9a35

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

cmd/mxcli-launcher/main.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
// background version checks, and upgrade/rollback.
66
//
77
// Routing:
8+
// - TTY commands (tui, playwright, serve, oql): run directly by exec'ing the
9+
// daemon binary with inherited stdin/stdout/stderr so the full terminal is
10+
// available (mouse events, resize, raw keyboard input).
811
// - Commands with -p <mpr>: forwarded to a per-MPR daemon (isolated process,
912
// 5-minute idle timeout, socket path derived from mpr path + binary mtime hash).
1013
// - Commands without -p: forwarded to the shared daemon at ~/.mxcli/daemon/mxcli.sock.
@@ -13,6 +16,7 @@ package main
1316
import (
1417
"fmt"
1518
"os"
19+
"os/exec"
1620
"path/filepath"
1721
)
1822

@@ -43,6 +47,25 @@ func main() {
4347
os.Exit(1)
4448
}
4549

50+
// TTY commands need a real terminal (stdin, stdout, stderr attached to the
51+
// calling process). Run them by exec'ing the daemon binary directly instead
52+
// of forwarding through the Unix socket, where stdin is disconnected and
53+
// stdout is JSON-framed (which corrupts escape codes).
54+
if isTTYCommand(args) {
55+
cmd := exec.Command(e.daemonBinaryPath(), args...)
56+
cmd.Stdin = os.Stdin
57+
cmd.Stdout = os.Stdout
58+
cmd.Stderr = os.Stderr
59+
if err := cmd.Run(); err != nil {
60+
if exitErr, ok := err.(*exec.ExitError); ok {
61+
os.Exit(exitErr.ExitCode())
62+
}
63+
fmt.Fprintf(os.Stderr, "mxcli: %v\n", err)
64+
os.Exit(1)
65+
}
66+
os.Exit(0)
67+
}
68+
4669
// Route to per-MPR daemon when -p is present; otherwise use shared daemon.
4770
var sockPath string
4871
if rawMPR := extractMPRFromArgs(args); rawMPR != "" {
@@ -77,6 +100,44 @@ func main() {
77100
os.Exit(exitCode)
78101
}
79102

103+
// ttyCommands is the set of subcommands that require a real TTY.
104+
var ttyCommands = map[string]bool{
105+
"tui": true, "serve": true, "oql": true, "playwright": true,
106+
}
107+
108+
// flagsWithValue lists root-level flags whose next token is their value, not a
109+
// subcommand. Knowing these lets isTTYCommand skip over -p <path>, etc.
110+
var flagsWithValue = map[string]bool{
111+
"-p": true, "--project": true,
112+
"-c": true, "--command": true,
113+
}
114+
115+
// isTTYCommand reports whether args requests a command that requires a real
116+
// terminal (interactive TUI, browser-launched viewer, streaming output).
117+
// These commands are exec'd directly with inherited stdin/stdout/stderr instead
118+
// of being forwarded through the Unix socket, where the terminal is unavailable.
119+
//
120+
// Handles both orderings: "tui -p file.mpr" and "-p file.mpr tui".
121+
func isTTYCommand(args []string) bool {
122+
skipNext := false
123+
for _, a := range args {
124+
if skipNext {
125+
skipNext = false
126+
continue
127+
}
128+
if flagsWithValue[a] {
129+
skipNext = true // consume the flag's value token
130+
continue
131+
}
132+
if len(a) > 0 && a[0] == '-' {
133+
continue // other flags (booleans, --key=val)
134+
}
135+
// First positional token is the subcommand name.
136+
return ttyCommands[a]
137+
}
138+
return false
139+
}
140+
80141
func printVersion(e *Env) {
81142
v := Version
82143
if LauncherBuild != "" {

cmd/mxcli-launcher/mpr_daemon_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,35 @@ func TestCleanupStaleMPRSockets_KeepsNonSocketFiles(t *testing.T) {
178178
t.Error("non-socket file should not have been removed")
179179
}
180180
}
181+
182+
// --- isTTYCommand ---
183+
184+
func TestIsTTYCommand_Tui(t *testing.T) {
185+
if !isTTYCommand([]string{"tui", "-p", "app.mpr"}) {
186+
t.Error("tui should be a TTY command")
187+
}
188+
}
189+
190+
func TestIsTTYCommand_TuiAfterProjectFlag(t *testing.T) {
191+
if !isTTYCommand([]string{"-p", "app.mpr", "tui"}) {
192+
t.Error("tui after -p should be detected")
193+
}
194+
}
195+
196+
func TestIsTTYCommand_ShowIsNot(t *testing.T) {
197+
if isTTYCommand([]string{"show", "modules"}) {
198+
t.Error("show is not a TTY command")
199+
}
200+
}
201+
202+
func TestIsTTYCommand_FlagOnly(t *testing.T) {
203+
if isTTYCommand([]string{"-p", "app.mpr", "-c", "show modules"}) {
204+
t.Error("flag-only args should not be TTY")
205+
}
206+
}
207+
208+
func TestIsTTYCommand_Serve(t *testing.T) {
209+
if !isTTYCommand([]string{"serve", "-p", "app.mpr"}) {
210+
t.Error("serve should be a TTY command")
211+
}
212+
}

0 commit comments

Comments
 (0)