WIP: working on tui and process killing

This commit is contained in:
Hayden Hargreaves 2026-04-14 21:58:56 -07:00
parent 24b00146bf
commit 58da1e3a64
11 changed files with 430 additions and 60 deletions

22
go.mod
View File

@ -3,3 +3,25 @@ module termtap.dev
go 1.26.1 go 1.26.1
require github.com/google/uuid v1.6.0 require github.com/google/uuid v1.6.0
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

41
go.sum
View File

@ -1,2 +1,43 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"syscall"
"time"
"termtap.dev/internal/model" "termtap.dev/internal/model"
"termtap.dev/internal/process" "termtap.dev/internal/process"
@ -38,7 +40,15 @@ func StartProcess(cmd model.Command, addr string, ch chan<- model.Message, sigCh
} }
if proc.Exec != nil { if proc.Exec != nil {
_ = proc.Exec.Process.Signal(sig) _ = process.SignalProcess(proc.Exec, sig)
go func() {
time.Sleep(1500 * time.Millisecond)
if process.ProcessAlive(proc.Exec) {
_ = process.SignalProcess(proc.Exec, syscall.SIGKILL)
}
}()
process.UpdateStatus(proc, false, ch) process.UpdateStatus(proc, false, ch)
} }
}() }()

View File

@ -1,81 +1,46 @@
package app package app
import ( import (
"fmt"
"log"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
func StartSession(cmd model.Command, addr string) error { type Session struct {
Messages <-chan model.Message
// Event type? sigCh chan os.Signal
msgs := make(chan model.Message, 128) stopOnce sync.Once
}
func StartSession(cmd model.Command, addr string) (*Session, error) {
msgs := make(chan model.Message, 256)
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
// Start process and proxy
go StartProxy(addr, msgs) go StartProxy(addr, msgs)
go StartProcess(cmd, addr, msgs, sigCh) go StartProcess(cmd, addr, msgs, sigCh)
var events []model.Message return &Session{
Messages: msgs,
sigCh: sigCh,
}, nil
}
var requests []model.Request func (s *Session) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
signal.Stop(s.sigCh)
for {
select { select {
case _ = <-sigCh: case s.sigCh <- syscall.SIGTERM:
fmt.Println("\n\nEVENTS")
printEvents(events)
fmt.Println("\n\nREQUESTS")
printRequests(requests)
return nil
case msg := <-msgs:
{
events = append(events, msg)
switch msg.Type {
case model.MessageTypeFatal:
return fmt.Errorf("%s", msg.Body)
case model.MessageTypeRequestStarted:
log.Printf("[%s] (%s) %s", msg.Type, msg.Request.ID.String(), msg.Body)
requests = append(requests, msg.Request)
case model.MessageTypeRequestFinished, model.MessageTypeRequestFailed:
log.Printf("[%s] (%s) %s", msg.Type, msg.Request.ID.String(), msg.Body)
for i := range requests {
if requests[i].ID == msg.Request.ID {
requests[i] = msg.Request
break
}
}
default: default:
log.Printf("[%s] %s", msg.Type, msg.Body)
}
}
}
}
}
// DEBUG
func printEvents(events []model.Message) {
for _, event := range events {
fmt.Printf("%+v\n", event)
}
}
func printRequests(reqs []model.Request) {
for _, req := range reqs {
fmt.Printf("%+v\n", req)
for k, v := range req.QueryMap {
fmt.Printf("key: %s, vals: %+v\n", k, v)
}
} }
})
} }

View File

@ -7,6 +7,7 @@ import (
"termtap.dev/internal/app" "termtap.dev/internal/app"
"termtap.dev/internal/model" "termtap.dev/internal/model"
"termtap.dev/internal/tui"
) )
// This should be configurable at some point, just in case they build on 8080 // This should be configurable at some point, just in case they build on 8080
@ -19,10 +20,15 @@ func Run(args []string) {
return return
} }
err := app.StartSession(cmd, proxy_addr) session, err := app.StartSession(cmd, proxy_addr)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
defer session.Stop()
if err := tui.Run(session.Messages); err != nil {
log.Fatalln(err)
}
} }
func parseCommand(args []string) (model.Command, bool) { func parseCommand(args []string) (model.Command, bool) {

View File

@ -17,6 +17,7 @@ func CommandString(c model.Command) string {
func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.Process { func NewProcess(cmd model.Command, addr string, ch chan<- model.Message) *model.Process {
proc := exec.Command(cmd.Name, cmd.Args...) proc := exec.Command(cmd.Name, cmd.Args...)
configureProcessForSignals(proc)
injectEnv(proc, addr) injectEnv(proc, addr)

View File

@ -0,0 +1,32 @@
//go:build !unix
package process
import (
"os"
"os/exec"
)
func configureProcessForSignals(cmd *exec.Cmd) {
_ = cmd
}
func SignalProcess(cmd *exec.Cmd, sig os.Signal) error {
if cmd == nil || cmd.Process == nil {
return nil
}
return cmd.Process.Signal(sig)
}
func ProcessAlive(cmd *exec.Cmd) bool {
if cmd == nil || cmd.Process == nil {
return false
}
if cmd.ProcessState == nil {
return true
}
return !cmd.ProcessState.Exited()
}

View File

@ -0,0 +1,46 @@
//go:build unix
package process
import (
"errors"
"os"
"os/exec"
"syscall"
)
func configureProcessForSignals(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func SignalProcess(cmd *exec.Cmd, sig os.Signal) error {
if cmd == nil || cmd.Process == nil {
return nil
}
pid := cmd.Process.Pid
if pid <= 0 {
return nil
}
sysSig, ok := sig.(syscall.Signal)
if !ok {
return cmd.Process.Signal(sig)
}
err := syscall.Kill(-pid, sysSig)
if err == nil || errors.Is(err, syscall.ESRCH) {
return nil
}
return cmd.Process.Signal(sig)
}
func ProcessAlive(cmd *exec.Cmd) bool {
if cmd == nil || cmd.Process == nil {
return false
}
err := syscall.Kill(-cmd.Process.Pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}

View File

@ -1,2 +1,65 @@
package tui package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
const (
maxEvents = 200
maxRequests = 200
)
type appMsg struct {
value model.Message
}
type modelErrMsg struct {
err error
}
type Model struct {
msgCh <-chan model.Message
events []model.Message
requestOrder []uuid.UUID
requests map[uuid.UUID]model.Request
width int
height int
}
func NewModel(msgCh <-chan model.Message) Model {
return Model{
msgCh: msgCh,
events: make([]model.Message, 0, maxEvents),
requestOrder: make([]uuid.UUID, 0, maxRequests),
requests: map[uuid.UUID]model.Request{},
width: 100,
height: 28,
}
}
func Run(msgCh <-chan model.Message) error {
p := tea.NewProgram(NewModel(msgCh), tea.WithAltScreen())
_, err := p.Run()
return err
}
func (m Model) Init() tea.Cmd {
return waitForAppMessage(m.msgCh)
}
func waitForAppMessage(msgCh <-chan model.Message) tea.Cmd {
return func() tea.Msg {
msg, ok := <-msgCh
if !ok {
return modelErrMsg{err: fmt.Errorf("event channel closed")}
}
return appMsg{value: msg}
}
}

View File

@ -1,2 +1,77 @@
package tui package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"termtap.dev/internal/model"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
}
return m, nil
case modelErrMsg:
m.events = append(m.events, model.Message{
Type: model.MessageTypeWarn,
Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
})
return m, nil
case appMsg:
m.pushEvent(msg.value)
m.applyMessage(msg.value)
return m, waitForAppMessage(m.msgCh)
}
return m, nil
}
func (m *Model) pushEvent(msg model.Message) {
m.events = append(m.events, msg)
if len(m.events) > maxEvents {
m.events = m.events[len(m.events)-maxEvents:]
}
}
func (m *Model) applyMessage(msg model.Message) {
switch msg.Type {
case model.MessageTypeRequestStarted:
m.upsertRequest(msg.Request, true)
case model.MessageTypeRequestFinished, model.MessageTypeRequestFailed:
m.upsertRequest(msg.Request, false)
}
}
func (m *Model) upsertRequest(req model.Request, addIfMissing bool) {
if req.ID == uuid.Nil {
return
}
_, exists := m.requests[req.ID]
if !exists && !addIfMissing {
return
}
if !exists {
m.requestOrder = append(m.requestOrder, req.ID)
if len(m.requestOrder) > maxRequests {
drop := m.requestOrder[0]
delete(m.requests, drop)
m.requestOrder = m.requestOrder[1:]
}
}
m.requests[req.ID] = req
}

View File

@ -1,2 +1,111 @@
package tui package tui
import (
"fmt"
"strings"
"termtap.dev/internal/model"
)
func (m Model) View() string {
eventLines := m.renderEvents(8)
requestLines := m.renderRequests(12)
return strings.Join([]string{
"termtap - live session",
fmt.Sprintf("events=%d requests=%d", len(m.events), len(m.requestOrder)),
"keys: q/esc/ctrl+c quit",
"",
"Recent events:",
eventLines,
"",
"Recent requests:",
requestLines,
}, "\n")
}
func (m Model) renderEvents(limit int) string {
if len(m.events) == 0 {
return " (none yet)"
}
start := len(m.events) - limit
if start < 0 {
start = 0
}
rows := make([]string, 0, len(m.events)-start)
for i := start; i < len(m.events); i++ {
e := m.events[i]
rows = append(rows, fmt.Sprintf(" [%s] %s", e.Type, truncate(e.Body, 100)))
}
return strings.Join(rows, "\n")
}
func (m Model) renderRequests(limit int) string {
if len(m.requestOrder) == 0 {
return " (none yet)"
}
start := len(m.requestOrder) - limit
if start < 0 {
start = 0
}
rows := make([]string, 0, len(m.requestOrder)-start)
for i := start; i < len(m.requestOrder); i++ {
id := m.requestOrder[i]
req, ok := m.requests[id]
if !ok {
continue
}
state := "done"
if req.Pending {
state = "pending"
} else if req.Failed {
state = "failed"
}
rows = append(rows, fmt.Sprintf(
" %s %s status=%d duration=%s state=%s",
req.Method,
requestPath(req),
req.Status,
req.Duration,
state,
))
}
if len(rows) == 0 {
return " (none yet)"
}
return strings.Join(rows, "\n")
}
func requestPath(req model.Request) string {
if req.URL != "" {
return truncate(req.URL, 80)
}
if req.RawURL != "" {
return truncate(req.RawURL, 80)
}
if req.Host != "" {
return truncate(req.Host, 80)
}
return "<unknown>"
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}