fix(shell): capture stdout only for gum commands

Amolith and Amp created

gum draws its UI to stderr and outputs selections to stdout. The
existing run_cmd() merges stderr into stdout with 2>&1, which pipes the
UI away from the terminal and breaks interactivity.

Add run_cmd_interactive() that captures stdout only, leaving stderr
connected to the terminal. Update all gum choose/table calls to use this
new function.

Assisted-by: Claude Opus 4.5 via Amp
Amp-Thread-ID: https://ampcode.com/threads/T-019bf101-334c-71af-bfae-eaa5eefdc17d
Co-authored-by: Amp <amp@ampcode.com>

Change summary

dist/wt              | 34 ++++++++++++++++++++++++----------
src/wt/cmd/clone.lua |  4 ++--
src/wt/cmd/list.lua  |  8 ++------
src/wt/cmd/new.lua   |  4 ++--
src/wt/shell.lua     | 18 ++++++++++++++++++
5 files changed, 48 insertions(+), 20 deletions(-)

Detailed changes

dist/wt 🔗

@@ -71,6 +71,24 @@ function M.run_cmd(cmd)
 	return output, code or exit.EXIT_SYSTEM_ERROR
 end
 
+---Execute command capturing stdout only (stderr stays on terminal)
+---Use for interactive commands like gum that draw UI to stderr
+---@param cmd string
+---@return string output
+---@return integer code
+function M.run_cmd_interactive(cmd)
+	local handle = io.popen(cmd)
+	if not handle then
+		return "", exit.EXIT_SYSTEM_ERROR
+	end
+	local output = handle:read("*a") or ""
+	local success, _, code = handle:close()
+	if success then
+		return output, 0
+	end
+	return output, code or exit.EXIT_SYSTEM_ERROR
+end
+
 ---Execute command silently, return success boolean
 ---@param cmd string
 ---@return boolean success
@@ -1065,7 +1083,7 @@ function M.cmd_clone(args)
 					local input = table.concat(keys, "\n")
 					local choose_type = own and "" or " --no-limit"
 					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
-					output, code = shell.run_cmd(cmd)
+					output, code = shell.run_cmd_interactive(cmd)
 					if code == 0 and output ~= "" then
 						for line in output:gmatch("[^\n]+") do
 							table.insert(selected_remotes, line)
@@ -1084,7 +1102,7 @@ function M.cmd_clone(args)
 			local input = table.concat(keys, "\n")
 			local choose_type = own and "" or " --no-limit"
 			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
-			output, code = shell.run_cmd(cmd)
+			output, code = shell.run_cmd_interactive(cmd)
 			if code == 0 and output ~= "" then
 				for line in output:gmatch("[^\n]+") do
 					table.insert(selected_remotes, line)
@@ -1332,7 +1350,7 @@ function M.cmd_new(args)
 				if #keys > 0 then
 					local input = table.concat(keys, "\n")
 					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
-					local output, code = shell.run_cmd(cmd)
+					local output, code = shell.run_cmd_interactive(cmd)
 					if code == 0 and output ~= "" then
 						for line in output:gmatch("[^\n]+") do
 							table.insert(selected_remotes, line)
@@ -1351,7 +1369,7 @@ function M.cmd_new(args)
 		if #keys > 0 then
 			local input = table.concat(keys, "\n")
 			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
-			local output, code = shell.run_cmd(cmd)
+			local output, code = shell.run_cmd_interactive(cmd)
 			if code == 0 and output ~= "" then
 				for line in output:gmatch("[^\n]+") do
 					table.insert(selected_remotes, line)
@@ -1837,12 +1855,8 @@ function M.cmd_list()
 	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
 	table_input = table_input:gsub("EOF", "eof")
 	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
-	local table_handle = io.popen(table_cmd, "r")
-	if not table_handle then
-		return
-	end
-	io.write(table_handle:read("*a") or "")
-	table_handle:close()
+	local table_output, _ = shell.run_cmd_interactive(table_cmd)
+	io.write(table_output)
 end
 
 return M

src/wt/cmd/clone.lua 🔗

@@ -116,7 +116,7 @@ function M.cmd_clone(args)
 					local input = table.concat(keys, "\n")
 					local choose_type = own and "" or " --no-limit"
 					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
-					output, code = shell.run_cmd(cmd)
+					output, code = shell.run_cmd_interactive(cmd)
 					if code == 0 and output ~= "" then
 						for line in output:gmatch("[^\n]+") do
 							table.insert(selected_remotes, line)
@@ -135,7 +135,7 @@ function M.cmd_clone(args)
 			local input = table.concat(keys, "\n")
 			local choose_type = own and "" or " --no-limit"
 			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
-			output, code = shell.run_cmd(cmd)
+			output, code = shell.run_cmd_interactive(cmd)
 			if code == 0 and output ~= "" then
 				for line in output:gmatch("[^\n]+") do
 					table.insert(selected_remotes, line)

src/wt/cmd/list.lua 🔗

@@ -71,12 +71,8 @@ function M.cmd_list()
 	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
 	table_input = table_input:gsub("EOF", "eof")
 	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
-	local table_handle = io.popen(table_cmd, "r")
-	if not table_handle then
-		return
-	end
-	io.write(table_handle:read("*a") or "")
-	table_handle:close()
+	local table_output, _ = shell.run_cmd_interactive(table_cmd)
+	io.write(table_output)
 end
 
 return M

src/wt/cmd/new.lua 🔗

@@ -87,7 +87,7 @@ function M.cmd_new(args)
 				if #keys > 0 then
 					local input = table.concat(keys, "\n")
 					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
-					local output, code = shell.run_cmd(cmd)
+					local output, code = shell.run_cmd_interactive(cmd)
 					if code == 0 and output ~= "" then
 						for line in output:gmatch("[^\n]+") do
 							table.insert(selected_remotes, line)
@@ -106,7 +106,7 @@ function M.cmd_new(args)
 		if #keys > 0 then
 			local input = table.concat(keys, "\n")
 			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
-			local output, code = shell.run_cmd(cmd)
+			local output, code = shell.run_cmd_interactive(cmd)
 			if code == 0 and output ~= "" then
 				for line in output:gmatch("[^\n]+") do
 					table.insert(selected_remotes, line)

src/wt/shell.lua 🔗

@@ -24,6 +24,24 @@ function M.run_cmd(cmd)
 	return output, code or exit.EXIT_SYSTEM_ERROR
 end
 
+---Execute command capturing stdout only (stderr stays on terminal)
+---Use for interactive commands like gum that draw UI to stderr
+---@param cmd string
+---@return string output
+---@return integer code
+function M.run_cmd_interactive(cmd)
+	local handle = io.popen(cmd)
+	if not handle then
+		return "", exit.EXIT_SYSTEM_ERROR
+	end
+	local output = handle:read("*a") or ""
+	local success, _, code = handle:close()
+	if success then
+		return output, 0
+	end
+	return output, code or exit.EXIT_SYSTEM_ERROR
+end
+
 ---Execute command silently, return success boolean
 ---@param cmd string
 ---@return boolean success