From 809ac1d2e88f120e4dd3f186ae690c6d74d5620d Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 14:59:48 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20session=20tab=20I/O=20=E2=80=94=20stdin?= =?UTF-8?q?=20direct=20write,=20pollSession=20triggers=20re-render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/tui/session.go | 78 +++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/pkg/tui/session.go b/pkg/tui/session.go index e364cdd..342e7e9 100644 --- a/pkg/tui/session.go +++ b/pkg/tui/session.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "io" "strings" "sync" "time" @@ -32,7 +33,8 @@ type SessionTab struct { done bool err error - stdinPipe chan []byte + stdinPipe io.WriteCloser + stdinMu sync.Mutex outputCh chan sessionOutputMsg doneCh chan sessionDoneMsg windowCh chan struct{} @@ -50,11 +52,10 @@ func NewSessionTab(host *models.Host, dataDir string) *SessionTab { width: 80, height: 24, - stdinPipe: make(chan []byte, 256), - outputCh: make(chan sessionOutputMsg, 64), - doneCh: make(chan sessionDoneMsg, 1), - windowCh: make(chan struct{}, 8), - closed: make(chan struct{}), + outputCh: make(chan sessionOutputMsg, 256), + doneCh: make(chan sessionDoneMsg, 1), + windowCh: make(chan struct{}, 8), + closed: make(chan struct{}), } } @@ -72,14 +73,15 @@ func (t *SessionTab) Init() tea.Cmd { return t.pollSession } -// pollSession polls for session output and done signals +// pollSession polls for session output and returns it as a message to trigger re-render func (t *SessionTab) pollSession() tea.Msg { select { case msg := <-t.outputCh: t.mu.Lock() t.buffer.WriteString(string(msg)) t.mu.Unlock() - return nil + // Return the msg to trigger Update → View re-render + return msg case done := <-t.doneCh: return done default: @@ -89,7 +91,6 @@ func (t *SessionTab) pollSession() tea.Msg { // connectAndStream connects to the host and streams output func (t *SessionTab) connectAndStream(ctx context.Context) { - // Create SSH client timeout := 30 * time.Second client := sshclient.NewClient(t.host, timeout) @@ -103,7 +104,6 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { t.connected = true t.mu.Unlock() - // Create session sshClient := client.GetClient() sess, err := sshClient.NewSession() if err != nil { @@ -119,7 +119,6 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { width := t.width height := t.height - // Request PTY modes := cryptossh.TerminalModes{ cryptossh.ECHO: 1, cryptossh.TTY_OP_ISPEED: 14400, @@ -132,13 +131,13 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { return } - // Set up pipes stdin, err := sess.StdinPipe() if err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stdin pipe failed: %w", err)} return } + t.stdinPipe = stdin stdout, err := sess.StdoutPipe() if err != nil { @@ -154,17 +153,14 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { return } - // Start shell if err := sess.Shell(); err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("shell start failed: %w", err)} return } - // Read stdout in a goroutine - stdoutDone := make(chan struct{}) + // Stream stdout go func() { - defer close(stdoutDone) buf := make([]byte, 4096) for { n, err := stdout.Read(buf) @@ -180,10 +176,8 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { } }() - // Read stderr in a goroutine - stderrDone := make(chan struct{}) + // Stream stderr go func() { - defer close(stderrDone) buf := make([]byte, 4096) for { n, err := stderr.Read(buf) @@ -195,22 +189,19 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { } if err != nil { return - } + } } }() - // Write stdin (from channel to SSH pipe) - stdinDone := make(chan struct{}) + // Handle window resize go func() { - defer close(stdinDone) for { select { - case data := <-t.stdinPipe: - stdin.Write(data) case <-t.windowCh: t.mu.Lock() - sess.WindowChange(t.height, t.width) + w, h := t.width, t.height t.mu.Unlock() + sess.WindowChange(h, w) case <-t.closed: return case <-ctx.Done(): @@ -219,16 +210,8 @@ func (t *SessionTab) connectAndStream(ctx context.Context) { } }() - // Wait for session to finish sess.Wait() - - // Cleanup - close(stdinDone) - <-stdoutDone - <-stderrDone - client.Close() - t.doneCh <- sessionDoneMsg{err: nil} } @@ -242,7 +225,7 @@ func (t *SessionTab) Update(msg tea.Msg) (Tab, tea.Cmd) { case tea.WindowSizeMsg: t.mu.Lock() t.width = msg.Width - t.height = msg.Height - 1 // account for status bar + t.height = msg.Height t.mu.Unlock() select { case t.windowCh <- struct{}{}: @@ -250,15 +233,19 @@ func (t *SessionTab) Update(msg tea.Msg) (Tab, tea.Cmd) { } case tea.KeyMsg: - // Send all key input to SSH stdin data := keyMsgToBytes(msg) if len(data) > 0 { - select { - case t.stdinPipe <- data: - default: + t.stdinMu.Lock() + if t.stdinPipe != nil { + t.stdinPipe.Write(data) } + t.stdinMu.Unlock() } + case sessionOutputMsg: + // Buffer already updated in pollSession, but we return pollSession + // to keep the polling loop alive + case sessionDoneMsg: t.mu.Lock() t.done = true @@ -277,7 +264,8 @@ func (t *SessionTab) View() string { defer t.mu.Unlock() if !t.connected { - return HighlightStyle.Render(fmt.Sprintf("Connecting to %s...", t.host.Name)) + return lipgloss.NewStyle().Height(t.height - 2).Render( + HighlightStyle.Render(fmt.Sprintf("Connecting to %s...", t.host.Name))) } if t.done && t.err != nil { @@ -294,18 +282,18 @@ func (t *SessionTab) View() string { content := t.buffer.String() if content == "" { - return "Connected. Waiting for output..." + return lipgloss.NewStyle().Height(t.height - 2).Render( + SubtitleStyle.Render("Connected. Waiting for output...")) } - // Only show last N lines to avoid unbounded memory lines := strings.Split(content, "\n") - const maxLines = 500 + const maxLines = 1000 if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] } visible := strings.Join(lines, "\n") - return lipgloss.NewStyle().MaxHeight(t.height - 2).Render(visible) + return lipgloss.NewStyle().Height(t.height - 3).Render(visible) } // Close terminates the session @@ -364,5 +352,3 @@ func keyMsgToBytes(msg tea.KeyMsg) []byte { return []byte(msg.String()) } } - -