2026-03-20 11:07:06 +00:00
|
|
|
|
// PicoClaw - Ultra-lightweight personal AI agent
|
|
|
|
|
|
// License: MIT
|
|
|
|
|
|
//
|
|
|
|
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
|
|
|
|
|
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"os/exec"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"runtime"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const pidFileName = "gateway.pid"
|
|
|
|
|
|
|
|
|
|
|
|
type gatewayStatus struct {
|
|
|
|
|
|
running bool
|
|
|
|
|
|
pid int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getPidPath() string {
|
|
|
|
|
|
home, err := os.UserHomeDir()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
home = "."
|
|
|
|
|
|
}
|
|
|
|
|
|
return filepath.Join(home, ".picoclaw", pidFileName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isProcessRunning(pid int) bool {
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
|
|
|
|
|
output, err := cmd.Output()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Contains(string(output), strconv.Itoa(pid))
|
|
|
|
|
|
} else if runtime.GOOS == "darwin" {
|
|
|
|
|
|
cmd := exec.Command("ps", "aux")
|
|
|
|
|
|
output, err := cmd.Output()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Linux
|
|
|
|
|
|
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
|
|
|
|
|
return err == nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getGatewayStatus() gatewayStatus {
|
|
|
|
|
|
pidPath := getPidPath()
|
|
|
|
|
|
data, err := os.ReadFile(pidPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return gatewayStatus{running: false}
|
|
|
|
|
|
}
|
|
|
|
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return gatewayStatus{running: false}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !isProcessRunning(pid) {
|
|
|
|
|
|
os.Remove(pidPath)
|
|
|
|
|
|
return gatewayStatus{running: false}
|
|
|
|
|
|
}
|
|
|
|
|
|
return gatewayStatus{
|
|
|
|
|
|
running: true,
|
|
|
|
|
|
pid: pid,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func startGateway() error {
|
|
|
|
|
|
status := getGatewayStatus()
|
|
|
|
|
|
if status.running {
|
|
|
|
|
|
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pidPath := getPidPath()
|
|
|
|
|
|
var cmd *exec.Cmd
|
|
|
|
|
|
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
|
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err := cmd.Start()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
2026-03-20 11:41:59 +00:00
|
|
|
|
cmd := exec.Command(
|
|
|
|
|
|
"wmic",
|
|
|
|
|
|
"process",
|
|
|
|
|
|
"where",
|
|
|
|
|
|
"name='picoclaw.exe' and commandline like '%gateway%'",
|
|
|
|
|
|
"get",
|
|
|
|
|
|
"processid",
|
|
|
|
|
|
)
|
2026-03-20 11:07:06 +00:00
|
|
|
|
output, err := cmd.Output()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to get gateway PID: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
|
|
|
|
for _, line := range lines[1:] {
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
|
if line == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
pid, err := strconv.Atoi(line)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
status = getGatewayStatus()
|
|
|
|
|
|
if !status.running {
|
|
|
|
|
|
return fmt.Errorf("failed to start gateway")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stopGateway() error {
|
|
|
|
|
|
status := getGatewayStatus()
|
|
|
|
|
|
if !status.running {
|
|
|
|
|
|
return fmt.Errorf("gateway is not running")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
|
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 多次尝试确认进程已停止
|
|
|
|
|
|
for i := 0; i < 5; i++ {
|
|
|
|
|
|
if !isProcessRunning(status.pid) {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
os.Remove(getPidPath())
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) newGatewayPage() tview.Primitive {
|
|
|
|
|
|
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
2026-03-20 11:41:59 +00:00
|
|
|
|
flex.SetBorder(true).
|
|
|
|
|
|
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
|
|
|
|
|
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
|
|
|
|
|
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
2026-03-20 11:07:06 +00:00
|
|
|
|
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
|
|
|
|
|
|
|
|
|
|
|
statusTV := tview.NewTextView().
|
|
|
|
|
|
SetDynamicColors(true).
|
|
|
|
|
|
SetTextAlign(tview.AlignCenter).
|
|
|
|
|
|
SetText("Checking status...")
|
|
|
|
|
|
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
|
|
|
|
|
|
|
|
|
|
|
var updateStatus func()
|
|
|
|
|
|
|
|
|
|
|
|
// 使用List作为按钮,保证显示和交互正常
|
|
|
|
|
|
buttons := tview.NewList()
|
|
|
|
|
|
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
|
|
|
|
|
buttons.SetMainTextColor(tcell.ColorWhite)
|
|
|
|
|
|
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
|
|
|
|
|
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
|
|
|
|
|
|
|
|
|
|
|
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
|
|
|
|
|
if !getGatewayStatus().running {
|
|
|
|
|
|
err := startGateway()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
a.showError(err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
updateStatus()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
|
|
|
|
|
if getGatewayStatus().running {
|
|
|
|
|
|
err := stopGateway()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
a.showError(err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
updateStatus()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
|
|
|
|
|
buttonFlex.
|
|
|
|
|
|
AddItem(tview.NewBox(), 0, 1, false).
|
|
|
|
|
|
AddItem(buttons, 20, 1, true).
|
|
|
|
|
|
AddItem(tview.NewBox(), 0, 1, false)
|
|
|
|
|
|
|
|
|
|
|
|
flex.
|
|
|
|
|
|
AddItem(tview.NewBox(), 0, 1, false).
|
|
|
|
|
|
AddItem(statusTV, 3, 1, false).
|
|
|
|
|
|
AddItem(tview.NewBox(), 0, 1, false).
|
|
|
|
|
|
AddItem(buttonFlex, 4, 1, true).
|
|
|
|
|
|
AddItem(tview.NewBox(), 0, 1, false)
|
|
|
|
|
|
|
|
|
|
|
|
updateStatus = func() {
|
|
|
|
|
|
status := getGatewayStatus()
|
|
|
|
|
|
if status.running {
|
|
|
|
|
|
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
|
|
|
|
|
buttons.SetItemText(0, " [gray]START[white] ", "")
|
|
|
|
|
|
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
|
|
|
|
|
buttons.SetItemText(0, " [lime]START[white] ", "")
|
|
|
|
|
|
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateStatus()
|
|
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
|
a.tapp.QueueUpdateDraw(updateStatus)
|
|
|
|
|
|
case <-done:
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
originalInputCapture := flex.GetInputCapture()
|
|
|
|
|
|
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
|
|
|
|
if event.Key() == tcell.KeyEscape {
|
|
|
|
|
|
close(done)
|
|
|
|
|
|
return a.goBack()
|
|
|
|
|
|
}
|
|
|
|
|
|
if originalInputCapture != nil {
|
|
|
|
|
|
return originalInputCapture(event)
|
|
|
|
|
|
}
|
|
|
|
|
|
return event
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
a.pageRefreshFns["gateway"] = updateStatus
|
|
|
|
|
|
|
|
|
|
|
|
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
|
|
|
|
|
}
|