diff --git a/internal/tui/app.go b/internal/tui/app.go index 394e3a2..1b564a2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -27,7 +27,6 @@ import ( ) type tickMsg time.Time -type kittyTickMsg time.Time type liveTickMsg time.Time // slow live capture (2s, unfocused) type spinnerTickMsg time.Time type globalStatsMsg session.GlobalStats @@ -98,16 +97,6 @@ func tickCmd() tea.Cmd { }) } -// kittyTickCmd fires a fast tick used only to watch for tmux window-switch -// transitions. tmux does not forward focus-out events on window switch, so -// we have to poll `PaneVisible()` ourselves. 250ms feels responsive without -// thrashing the tmux control channel. -func kittyTickCmd() tea.Cmd { - return tea.Tick(250*time.Millisecond, func(t time.Time) tea.Msg { - return kittyTickMsg(t) - }) -} - func liveTickCmd() tea.Cmd { return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return liveTickMsg(t) @@ -519,6 +508,7 @@ type App struct { liveInputPane tmux.Pane liveInputModal inputModal liveInputProjDir string // project path for $EDITOR cwd + liveInputSeq int // generation of the latest openLiveInput lookup // Pane proxy: unified live preview + shell-in-preview paneProxy *paneProxyState @@ -1136,6 +1126,14 @@ func (a *App) Init() tea.Cmd { } func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := a.update(msg) + if a.state == viewSessions { + a.syncSessionSplitLayout() + } + return m, cmd +} + +func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: first := a.width == 0 && a.height == 0 @@ -1149,7 +1147,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Enable focus reporting so we get BlurMsg on tmux window switch if first && kitty.Supported() { - cmd = tea.Batch(cmd, tea.EnableReportFocus, kittyTickCmd()) + cmd = tea.Batch(cmd, tea.EnableReportFocus, startKittyWatch(true)) } return a, cmd @@ -1267,6 +1265,16 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Re-scan plugins to reflect changes return a.openPluginExplorer() + case worktreeDoneMsg: + return a, a.handleWorktreeDone(msg) + + case sessionsKilledMsg: + a.handleSessionsKilled(msg) + return a, nil + + case liveInputPaneMsg: + return a, a.handleLiveInputPane(msg) + case liveInputSentMsg: if msg.err != nil { a.copiedMsg = "Send failed" @@ -1300,23 +1308,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := a.handleTick() return a, tea.Batch(cmd, tickCmd()) - case kittyTickMsg: - // Fast visibility poll: detect tmux window-switch transitions that - // don't surface as BlurMsg. When our pane becomes invisible, clear - // any lingering Kitty graphics so they don't bleed through to the - // window the user switched to. - if kitty.Supported() { - visible := kitty.PaneVisible() - if !visible && a.termFocused { - a.termFocused = false - fmt.Fprint(os.Stdout, kitty.ClearImages()) - _ = os.Stdout.Sync() - } else if visible && !a.termFocused { - a.termFocused = true - kitty.InvalidatePaneOffset() - } - } - return a, kittyTickCmd() + case kittyVisibilityMsg: + // Detect tmux window-switch transitions that don't surface as + // BlurMsg; see kitty_watch.go. + return a, a.handleKittyVisibility(msg) case liveTickMsg: // 2s tick: async capture for passive updates (process output, unfocused view) @@ -4342,32 +4337,6 @@ func (a *App) bulkResume(selected []session.Session) (tea.Model, tea.Cmd) { return a, nil } -func (a *App) bulkKill(selected []session.Session) (tea.Model, tea.Cmd) { - count := 0 - for _, s := range selected { - if !s.IsLive { - continue - } - pane, found := tmux.FindPane(s.ProjectPath, s.ID) - if !found { - continue - } - target := pane.Session + ":" + pane.Window + "." + pane.Pane - exec.Command("tmux", "send-keys", "-t", target, "C-c").Run() - exec.Command("tmux", "send-keys", "-t", target, "C-c").Run() - if a.paneProxy != nil && a.paneProxy.sessID == s.ID { - a.closePaneProxy() - a.sessPreviewMode = sessPreviewConversation - a.sessSplit.CacheKey = "" - a.sessSplit.Focus = false - } - count++ - } - a.clearMultiSelection() - a.copiedMsg = fmt.Sprintf("Killed %d", count) - return a, nil -} - func (a *App) bulkInput(selected []session.Session) (tea.Model, tea.Cmd) { if !tmux.InTmux() { a.copiedMsg = "Requires tmux" @@ -4396,32 +4365,6 @@ func (a *App) bulkInput(selected []session.Session) (tea.Model, tea.Cmd) { return a, nil } -// killLiveSession sends SIGHUP to the Claude process in the session's tmux pane. -func (a *App) killLiveSession(sess session.Session) (tea.Model, tea.Cmd) { - pane, found := tmux.FindPane(sess.ProjectPath, sess.ID) - if !found { - // Fallback: try to find any Claude process for this path - pane, found = tmux.FindPane(sess.ProjectPath) - if !found { - a.copiedMsg = "No tmux pane found" - return a, nil - } - } - // Send ctrl+c then ctrl+d to gracefully stop Claude - target := pane.Session + ":" + pane.Window + "." + pane.Pane - exec.Command("tmux", "send-keys", "-t", target, "C-c").Run() - exec.Command("tmux", "send-keys", "-t", target, "C-c").Run() - // Close any live preview for this session - if a.paneProxy != nil && a.paneProxy.sessID == sess.ID { - a.closePaneProxy() - a.sessPreviewMode = sessPreviewConversation - a.sessSplit.CacheKey = "" - a.sessSplit.Focus = false - } - a.copiedMsg = "Killed" - return a, nil -} - func (a *App) resolveImagePath(pasteID int) string { home, _ := os.UserHomeDir() p, err := session.ExtractImageToTemp(home, a.currentSess.FilePath, a.currentSess.ID, pasteID) @@ -4714,102 +4657,6 @@ func (a *App) delayedRefreshCmd() tea.Cmd { type delayedRefreshMsg struct{ remaining int } -// executeNewWorktreeSession creates a git worktree and spawns a new Claude session in it. -func (a *App) executeNewWorktreeSession(sess session.Session, branch string) (tea.Model, tea.Cmd) { - out, err := exec.Command("git", "-C", sess.ProjectPath, "rev-parse", "--show-toplevel").Output() - if err != nil { - a.copiedMsg = "Not a git repo" - return a, nil - } - repoRoot := strings.TrimSpace(string(out)) - wtPath := filepath.Join(repoRoot, a.config.WorktreeDir, branch) - - // Try adding worktree - if err := exec.Command("git", "-C", repoRoot, "worktree", "add", wtPath, branch).Run(); err != nil { - if err2 := exec.Command("git", "-C", repoRoot, "worktree", "add", "-b", branch, wtPath).Run(); err2 != nil { - a.copiedMsg = "Worktree failed: " + err2.Error() - return a, nil - } - } - - // Spawn new Claude session in the worktree - name := filepath.Base(repoRoot) + "/" + branch - if tmux.InTmux() { - if err := tmux.NewWindowClaudeNewWithConfig(name, wtPath, a.config.Claude); err != nil { - a.copiedMsg = "Spawn failed" - return a, nil - } - a.copiedMsg = fmt.Sprintf("New session → %s/%s", a.config.WorktreeDir, branch) - return a, a.delayedRefreshCmd() - } - cmd, err := a.claudeCmd(wtPath) - if err != nil { - a.copiedMsg = "Claude command failed: " + err.Error() - return a, nil - } - return a, tea.ExecProcess(cmd, func(err error) tea.Msg { - return tea.QuitMsg{} - }) -} - -func (a *App) executeWorktree(sess session.Session, name string) (tea.Model, tea.Cmd) { - // Get repo root - out, err := exec.Command("git", "-C", sess.ProjectPath, "rev-parse", "--show-toplevel").Output() - if err != nil { - a.copiedMsg = "Not a git repo" - return a, nil - } - repoRoot := strings.TrimSpace(string(out)) - - // Determine branch - branch := sess.GitBranch - if branch == "" { - bOut, err := exec.Command("git", "-C", sess.ProjectPath, "branch", "--show-current").Output() - if err != nil { - a.copiedMsg = "Cannot determine branch" - return a, nil - } - branch = strings.TrimSpace(string(bOut)) - } - - wtPath := filepath.Join(repoRoot, a.config.WorktreeDir, name) - - // Try adding worktree on existing branch first; if it fails because - // the branch is already checked out, create a new branch from it. - if err := exec.Command("git", "-C", repoRoot, "worktree", "add", wtPath, branch).Run(); err != nil { - if err2 := exec.Command("git", "-C", repoRoot, "worktree", "add", "-b", name, wtPath, branch).Run(); err2 != nil { - a.copiedMsg = "Worktree failed: " + err2.Error() - return a, nil - } - } - - // Move session data to the new worktree path - oldPath := sess.ProjectPath - if err := session.MoveProject(oldPath, wtPath); err != nil { - a.copiedMsg = "Move failed: " + err.Error() - return a, nil - } - - home, _ := os.UserHomeDir() - newName := session.ShortenPath(wtPath, home) - - // Update in-memory sessions - for i := range a.sessions { - if a.sessions[i].ProjectPath == oldPath { - a.sessions[i].ProjectPath = wtPath - a.sessions[i].ProjectName = newName - a.sessions[i].IsWorktree = true - oldEncoded := session.EncodeProjectPath(oldPath) - newEncoded := session.EncodeProjectPath(wtPath) - a.sessions[i].FilePath = strings.Replace(a.sessions[i].FilePath, oldEncoded, newEncoded, 1) - } - } - - a.rebuildSessionList() - a.copiedMsg = fmt.Sprintf("Worktree created → %s/%s", a.config.WorktreeDir, name) - return a, nil -} - // newSession opens a new Claude session (without --resume) in the selected // session's project directory, or CWD if no session is selected. func (a *App) newSession() (tea.Model, tea.Cmd) { @@ -4880,63 +4727,6 @@ func (a *App) executeCmdNewSession(input string) (tea.Model, tea.Cmd) { }) } -// executeCmdWorktreeNew handles "worktree:new " / "wt:new ". -// It creates a git worktree for the given branch and opens a new Claude session in it. -func (a *App) executeCmdWorktreeNew(input string) (tea.Model, tea.Cmd) { - parts := strings.Fields(input) - if len(parts) < 2 { - a.copiedMsg = "Usage: worktree:new " - return a, nil - } - branch := parts[len(parts)-1] - - sess, ok := a.selectedSession() - if !ok || sess.ProjectPath == "" { - a.copiedMsg = "No session with project selected" - return a, nil - } - - // Get repo root - out, err := exec.Command("git", "-C", sess.ProjectPath, "rev-parse", "--show-toplevel").Output() - if err != nil { - a.copiedMsg = "Not a git repo" - return a, nil - } - repoRoot := strings.TrimSpace(string(out)) - - wtPath := filepath.Join(repoRoot, a.config.WorktreeDir, branch) - - // Try creating worktree: first on existing branch, then create new branch - if err := exec.Command("git", "-C", repoRoot, "worktree", "add", wtPath, branch).Run(); err != nil { - if err2 := exec.Command("git", "-C", repoRoot, "worktree", "add", "-b", branch, wtPath).Run(); err2 != nil { - a.copiedMsg = "Worktree failed: " + err2.Error() - return a, nil - } - } - - // Open new Claude session in the worktree - windowName := branch - if tmux.InTmux() { - if err := tmux.NewWindowClaudeNewWithConfig(windowName, wtPath, a.config.Claude); err != nil { - a.copiedMsg = "Worktree created but spawn failed" - } else { - a.copiedMsg = fmt.Sprintf("Worktree + session → %s/%s", a.config.WorktreeDir, branch) - } - return a, nil - } - - // Non-tmux: take over terminal - cmd, err := a.claudeCmd(wtPath) - if err != nil { - a.copiedMsg = "Claude command failed: " + err.Error() - return a, nil - } - a.copiedMsg = fmt.Sprintf("Worktree created → %s/%s", a.config.WorktreeDir, branch) - return a, tea.ExecProcess(cmd, func(err error) tea.Msg { - return tea.QuitMsg{} - }) -} - func (a *App) jumpToTmuxPane(projectPath string, sessionID ...string) (tea.Model, tea.Cmd) { pane, found := tmux.FindPane(projectPath, sessionID...) if !found { @@ -4953,50 +4743,6 @@ func (a *App) jumpToTmuxPane(projectPath string, sessionID ...string) (tea.Model // liveInputSentMsg is sent after async tmuxSendKeys completes. type liveInputSentMsg struct{ err error } -func (a *App) openLiveInput(projectPath string, sessionID ...string) (tea.Model, tea.Cmd) { - if !tmux.InTmux() { - a.copiedMsg = "Requires tmux" - return a, nil - } - pane, found := tmux.FindPane(projectPath, sessionID...) - if !found || !tmux.HasClaude(pane.PID) { - a.copiedMsg = "No live Claude pane" - return a, nil - } - a.liveInputPane = pane - a.liveInputProjDir = projectPath - - // If user prefers $EDITOR, skip inline modal and open editor directly - if a.editorInput { - return a, func() tea.Msg { - tmpFile, err := os.CreateTemp("", "ccx-input-*.md") - if err != nil { - return liveInputSentMsg{err: err} - } - tmpFile.Close() - defer os.Remove(tmpFile.Name()) - - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vim" - } - cmd := fmt.Sprintf("cd %s && %s %s", shellescape(projectPath), editor, tmpFile.Name()) - exec.Command("tmux", "display-popup", "-E", "-w", "80%", "-h", "70%", cmd).Run() - - content, readErr := os.ReadFile(tmpFile.Name()) - if readErr != nil || len(strings.TrimSpace(string(content))) == 0 { - return liveInputSentMsg{err: fmt.Errorf("empty")} - } - sendText := strings.TrimRight(string(content), "\n") - return liveInputSentMsg{err: tmux.SendKeys(pane, sendText)} - } - } - - a.liveInputModal = newInputModal() - a.liveInputActive = true - return a, nil -} - func (a *App) handleLiveInputKey(key string) (tea.Model, tea.Cmd) { action := a.liveInputModal.handleKey(key) @@ -5419,13 +5165,18 @@ func previewDispatchesFromView(mode sessPreview) bool { previewLoadsOffLoop(mode) } -func (a *App) renderSessionSplit() string { +// syncSessionSplitLayout sizes the session list and preview viewport for the +// current window and re-wraps cached preview content when the size changed. +// It runs after every Update rather than inside View: View must not write +// model state, or rendering twice (tests, render caches) stops being +// deterministic. +func (a *App) syncSessionSplitLayout() { if a.sessionList.Width() == 0 { - return "" + return } clampPaginator(&a.sessionList) if !a.sessSplit.Show || a.width < 40 || a.height < 10 { - return a.sessionList.View() + return } listW := a.sessSplit.ListWidth(a.width, a.splitRatio) @@ -5508,13 +5259,32 @@ func (a *App) renderSessionSplit() string { a.sessSplit.Preview.GotoBottom() } + // Live preview: always snap to bottom after all updates/resizes + if a.sessPreviewMode == sessPreviewLive && a.paneProxy != nil { + a.sessSplit.Preview.GotoBottom() + } + clampPaginator(&a.sessionList) +} + +// renderSessionSplit draws the list/preview split. It only reads state; +// sizing happens in syncSessionSplitLayout. +func (a *App) renderSessionSplit() string { + if a.sessionList.Width() == 0 { + return "" + } + if !a.sessSplit.Show || a.width < 40 || a.height < 10 { + return a.sessionList.View() + } + + listW := a.sessSplit.ListWidth(a.width, a.splitRatio) + previewW := max(a.width-listW-1, 1) + contentH := max(a.height-3, 1) + borderColor := colorBorderDim if a.sessSplit.Focus { borderColor = colorBorderFocused } - clampPaginator(&a.sessionList) - left := a.sessionList.View() right := a.sessSplit.Preview.View() diff --git a/internal/tui/inputmodal.go b/internal/tui/inputmodal.go index d8b5ce2..f31471b 100644 --- a/internal/tui/inputmodal.go +++ b/internal/tui/inputmodal.go @@ -5,6 +5,7 @@ import ( "unicode/utf8" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) // inputModal is a simple multi-line text input shown as a modal overlay. @@ -131,13 +132,69 @@ func (m *inputModal) insertChar(r rune) { m.cursorCol++ } -// ensureVisible adjusts scrollOff so the cursor row is visible. -func (m *inputModal) ensureVisible(visibleH int) { - if m.cursorRow < m.scrollOff { - m.scrollOff = m.cursorRow +// inputTabWidth matches lipgloss's default tab expansion, so wrapping math +// agrees with what the bordered style finally renders. +const inputTabWidth = 4 + +func inputCellWidth(r rune) int { + if r == '\t' { + return inputTabWidth } - if m.cursorRow >= m.scrollOff+visibleH { - m.scrollOff = m.cursorRow - visibleH + 1 + return max(runewidth.RuneWidth(r), 0) +} + +// inputVisualRow is one on-screen row: runes [start,end) of logical line. +type inputVisualRow struct { + line, start, end int +} + +// layout soft-wraps every logical line into rows of at most width terminal +// cells and returns the index of the row holding the cursor. Wrapping is by +// cell width, not rune count: a Hangul/CJK rune occupies two cells, and +// counting runes let such lines overflow the modal border. +func (m *inputModal) layout(width int) ([]inputVisualRow, int) { + width = max(width, 2) // a wide rune must always fit on a row + var rows []inputVisualRow + cursorVis := 0 + for li, line := range m.lines { + runes := []rune(line) + first := len(rows) + start, w := 0, 0 + for i, r := range runes { + cw := inputCellWidth(r) + if w+cw > width && i > start { + rows = append(rows, inputVisualRow{li, start, i}) + start, w = i, 0 + } + w += cw + } + rows = append(rows, inputVisualRow{li, start, len(runes)}) + // A cursor at end-of-line draws a one-cell block; if the last row + // is already full the block belongs on a fresh row. + if li == m.cursorRow && m.cursorCol >= len(runes) && w+1 > width { + rows = append(rows, inputVisualRow{li, len(runes), len(runes)}) + } + if li == m.cursorRow { + cursorVis = first + for vi := first; vi < len(rows); vi++ { + r := rows[vi] + if m.cursorCol >= r.start && (m.cursorCol < r.end || vi == len(rows)-1) { + cursorVis = vi + break + } + } + } + } + return rows, cursorVis +} + +// ensureVisible adjusts scrollOff (in visual rows) so cursorVis is visible. +func (m *inputModal) ensureVisible(cursorVis, visibleH int) { + if cursorVis < m.scrollOff { + m.scrollOff = cursorVis + } + if cursorVis >= m.scrollOff+visibleH { + m.scrollOff = cursorVis - visibleH + 1 } } @@ -159,40 +216,31 @@ func (m *inputModal) render(bg string, screenW, screenH int) string { helpText := dimStyle.Render("^S:send ^E:$EDITOR esc:cancel") contentH := min(screenH/2, 20) - m.ensureVisible(contentH) + m.clampCursor() + rows, cursorVis := m.layout(innerW) + m.ensureVisible(cursorVis, contentH) var body strings.Builder body.WriteString(title + "\n") body.WriteString(strings.Repeat("─", innerW) + "\n") - endRow := m.scrollOff + contentH - if endRow > len(m.lines) { - endRow = len(m.lines) - } + endRow := min(m.scrollOff+contentH, len(rows)) cursorStyle := lipgloss.NewStyle().Reverse(true) - for i := m.scrollOff; i < endRow; i++ { - line := m.lines[i] - runes := []rune(line) - if i == m.cursorRow { - col := m.cursorCol - if col > len(runes) { - col = len(runes) - } - before := string(runes[:col]) - var ch, after string - if col < len(runes) { - ch = string(runes[col]) - after = string(runes[col+1:]) - } else { - ch = " " - } - body.WriteString(before + cursorStyle.Render(ch) + after + "\n") - } else { - if utf8.RuneCountInString(line) > innerW { - line = string([]rune(line)[:innerW]) - } - body.WriteString(line + "\n") + for vi := m.scrollOff; vi < endRow; vi++ { + r := rows[vi] + runes := []rune(m.lines[r.line])[r.start:r.end] + if vi != cursorVis { + body.WriteString(string(runes) + "\n") + continue + } + col := m.cursorCol - r.start + before := string(runes[:col]) + ch, after := " ", "" + if col < len(runes) { + ch = string(runes[col]) + after = string(runes[col+1:]) } + body.WriteString(before + cursorStyle.Render(ch) + after + "\n") } for i := endRow - m.scrollOff; i < contentH; i++ { diff --git a/internal/tui/inputmodal_test.go b/internal/tui/inputmodal_test.go new file mode 100644 index 0000000..6aad8da --- /dev/null +++ b/internal/tui/inputmodal_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func typeInto(m *inputModal, s string) { + for _, r := range s { + m.handleKey(string(r)) + } +} + +// Hangul runes are two cells wide. Wrapping by rune count let a line of +// Korean text run to twice the modal's inner width and break the border. +func TestInputModalWrapsHangulByCellWidth(t *testing.T) { + m := newInputModal() + text := strings.Repeat("안녕하세요 반갑습니다 ", 8) + typeInto(&m, text) + + const innerW = 20 + rows, cursorVis := m.layout(innerW) + if len(rows) < 2 { + t.Fatalf("expected the Hangul line to wrap, got %d row(s)", len(rows)) + } + var joined strings.Builder + for _, r := range rows { + seg := string([]rune(m.lines[r.line])[r.start:r.end]) + if w := lipgloss.Width(seg); w > innerW { + t.Fatalf("row %q is %d cells, exceeds %d", seg, w, innerW) + } + joined.WriteString(seg) + } + if joined.String() != text { + t.Fatalf("wrapping lost text:\n got %q\nwant %q", joined.String(), text) + } + if cursorVis != len(rows)-1 { + t.Fatalf("cursor at end of text should be on the last row, got %d of %d", cursorVis, len(rows)) + } + + // Cursor in the middle lands on the row that contains it. + m.handleKey("home") + for i := 0; i < 15; i++ { + m.handleKey("right") + } + rows, cursorVis = m.layout(innerW) + r := rows[cursorVis] + if m.cursorCol < r.start || m.cursorCol >= r.end { + t.Fatalf("cursor col %d not inside row [%d,%d)", m.cursorCol, r.start, r.end) + } +} + +// The modal must keep its fixed height: title, separator, contentH rows, +// separator, help, plus the border. With rune-count truncation a Hangul row +// was twice the inner width, lipgloss re-wrapped it, and the modal grew and +// shifted every row below it. +func TestInputModalRenderKeepsFixedHeightWithHangul(t *testing.T) { + m := newInputModal() + typeInto(&m, strings.Repeat("한글입력테스트", 12)) + m.handleKey("enter") + typeInto(&m, strings.Repeat("가", 50)) + m.handleKey("up") // cursor off the second line: it renders as a plain row + + const screenW, screenH = 80, 40 + contentH := min(screenH/2, 20) + out := m.render("", screenW, screenH) + lines := 0 + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(ansi.Strip(line)) != "" { + lines++ + } + if w := lipgloss.Width(line); w > screenW { + t.Fatalf("line is %d cells, wider than screen %d", w, screenW) + } + } + if want := contentH + 4 + 2; lines != want { + t.Fatalf("modal has %d lines, want %d\n%s", lines, want, out) + } +} diff --git a/internal/tui/kitty_watch.go b/internal/tui/kitty_watch.go new file mode 100644 index 0000000..3c4d885 --- /dev/null +++ b/internal/tui/kitty_watch.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/sendbird/ccx/internal/kitty" +) + +// kittyVisibilityMsg reports that our tmux pane's visibility changed. +type kittyVisibilityMsg struct{ visible bool } + +// kittyWatchInterval is how often the watcher polls tmux. tmux does not +// forward focus-out events on window switch, so we have to poll; 250ms feels +// responsive without thrashing the tmux control channel. +const kittyWatchInterval = 250 * time.Millisecond + +// kittyWatchCmd polls probe off the UI loop and returns only when the +// visibility differs from last. The previous design delivered a tick message +// to Update every 250ms and ran `tmux display-message` synchronously there, +// which blocked input handling and forced a full View() four times a second +// even when nothing changed. Here the steady state produces no messages. +func kittyWatchCmd(last bool, probe func() bool, every time.Duration) tea.Cmd { + return func() tea.Msg { + for { + time.Sleep(every) + if v := probe(); v != last { + return kittyVisibilityMsg{visible: v} + } + } + } +} + +// startKittyWatch arms the watcher against the real tmux probe. +func startKittyWatch(last bool) tea.Cmd { + return kittyWatchCmd(last, kitty.PaneVisible, kittyWatchInterval) +} + +// handleKittyVisibility applies a visibility transition and re-arms the +// watcher with the newly observed value. +func (a *App) handleKittyVisibility(msg kittyVisibilityMsg) tea.Cmd { + if !msg.visible && a.termFocused { + a.termFocused = false + // tmux stops forwarding our pane's passthrough output once the + // window switches away, so clear immediately instead of waiting + // for the next render. + if kitty.Supported() { + fmt.Fprint(os.Stdout, kitty.ClearImages()) + _ = os.Stdout.Sync() + } + } else if msg.visible && !a.termFocused { + a.termFocused = true + kitty.InvalidatePaneOffset() + } + return startKittyWatch(msg.visible) +} diff --git a/internal/tui/kitty_watch_test.go b/internal/tui/kitty_watch_test.go new file mode 100644 index 0000000..38ff072 --- /dev/null +++ b/internal/tui/kitty_watch_test.go @@ -0,0 +1,50 @@ +package tui + +import ( + "testing" + "time" +) + +// The watcher must stay silent while visibility is unchanged and only emit a +// message on a transition, so an idle app gets no 250ms Update/View churn. +func TestKittyWatchEmitsOnlyOnTransition(t *testing.T) { + seq := []bool{true, true, true, false} + calls := 0 + probe := func() bool { + v := seq[min(calls, len(seq)-1)] + calls++ + return v + } + msg := kittyWatchCmd(true, probe, time.Millisecond)() + vm, ok := msg.(kittyVisibilityMsg) + if !ok { + t.Fatalf("msg = %T, want kittyVisibilityMsg", msg) + } + if vm.visible { + t.Fatal("expected visible=false transition") + } + if calls != 4 { + t.Fatalf("probe calls = %d, want 4 (unchanged polls must not return)", calls) + } +} + +func TestKittyVisibilityMsgTogglesFocusAndRearms(t *testing.T) { + a := newTestApp(fakeSessions()) + a.termFocused = true + + _, cmd := a.Update(kittyVisibilityMsg{visible: false}) + if a.termFocused { + t.Fatal("hidden pane should clear termFocused") + } + if cmd == nil { + t.Fatal("watcher must be re-armed") + } + + _, cmd = a.Update(kittyVisibilityMsg{visible: true}) + if !a.termFocused { + t.Fatal("visible pane should restore termFocused") + } + if cmd == nil { + t.Fatal("watcher must be re-armed") + } +} diff --git a/internal/tui/outputs_actions_test.go b/internal/tui/outputs_actions_test.go index 9e4240d..5412cf0 100644 --- a/internal/tui/outputs_actions_test.go +++ b/internal/tui/outputs_actions_test.go @@ -27,6 +27,10 @@ func outputsDigestApp(t *testing.T, rows []session.SessionOutput) *App { app.sessPreviewMode = sessPreviewOutputs app.sessSplit.Show = true app.sessSplit.Focus = true + // Settle the split layout first, as the first Update after these + // changes would; otherwise that pass re-collects the digest and + // replaces the injected rows. + app.syncSessionSplitLayout() app.sessOutputsCacheID = "maker" app.sessOutputsRows = rows app.sessOutputsCursor = 0 diff --git a/internal/tui/pane_actions.go b/internal/tui/pane_actions.go new file mode 100644 index 0000000..6902889 --- /dev/null +++ b/internal/tui/pane_actions.go @@ -0,0 +1,184 @@ +package tui + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" +) + +// tmux lookups (FindPane lists every pane and walks the process table) and +// send-keys used to run inside Update, stalling input for each call. They now +// run in tea.Cmds; results come back as messages. The indirections below +// exist so tests can observe that no tmux call happens on the Update path. +var ( + findPane = tmux.FindPane + paneHasClaude = tmux.HasClaude + sendInterruptK = func(target string) error { + out, err := exec.Command("tmux", "send-keys", "-t", target, "C-c").CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%w: %s", err, msg) + } + } + return err + } +) + +// killTarget identifies a live session to stop. +type killTarget struct { + id, projectPath string + // anyPane allows falling back to any Claude pane in projectPath when no + // pane runs this exact session (single-session kill only). + anyPane bool +} + +// sessionsKilledMsg reports the outcome of killSessionsCmd. +type sessionsKilledMsg struct { + bulk bool + killed []string // session IDs that received the interrupts + notFound int + errs []error +} + +// killSessionsCmd sends Ctrl-C twice to each target's pane to stop Claude +// gracefully. send-keys errors are collected and reported instead of being +// dropped, so a failed kill no longer reads as "Killed". +func killSessionsCmd(targets []killTarget, bulk bool) tea.Cmd { + return func() tea.Msg { + res := sessionsKilledMsg{bulk: bulk} + for _, t := range targets { + pane, found := findPane(t.projectPath, t.id) + if !found && t.anyPane { + pane, found = findPane(t.projectPath) + } + if !found { + res.notFound++ + continue + } + target := pane.Session + ":" + pane.Window + "." + pane.Pane + if err := errors.Join(sendInterruptK(target), sendInterruptK(target)); err != nil { + res.errs = append(res.errs, fmt.Errorf("%s: %w", target, err)) + continue + } + res.killed = append(res.killed, t.id) + } + return res + } +} + +func (a *App) bulkKill(selected []session.Session) (tea.Model, tea.Cmd) { + var targets []killTarget + for _, s := range selected { + if s.IsLive { + targets = append(targets, killTarget{id: s.ID, projectPath: s.ProjectPath}) + } + } + a.clearMultiSelection() + if len(targets) == 0 { + a.copiedMsg = "Killed 0" + return a, nil + } + return a, killSessionsCmd(targets, true) +} + +// killLiveSession stops the Claude process in the session's tmux pane. +func (a *App) killLiveSession(sess session.Session) (tea.Model, tea.Cmd) { + return a, killSessionsCmd([]killTarget{{id: sess.ID, projectPath: sess.ProjectPath, anyPane: true}}, false) +} + +func (a *App) handleSessionsKilled(msg sessionsKilledMsg) { + for _, id := range msg.killed { + // Close any live preview for a killed session. + if a.paneProxy != nil && a.paneProxy.sessID == id { + a.closePaneProxy() + a.sessPreviewMode = sessPreviewConversation + a.sessSplit.CacheKey = "" + a.sessSplit.Focus = false + } + } + switch { + case len(msg.errs) > 0 && msg.bulk: + a.copiedMsg = fmt.Sprintf("Killed %d, %d failed: %v", len(msg.killed), len(msg.errs), msg.errs[0]) + case len(msg.errs) > 0: + a.copiedMsg = fmt.Sprintf("Kill failed: %v", msg.errs[0]) + case msg.bulk: + a.copiedMsg = fmt.Sprintf("Killed %d", len(msg.killed)) + case msg.notFound > 0: + a.copiedMsg = "No tmux pane found" + default: + a.copiedMsg = "Killed" + } +} + +// liveInputPaneMsg carries the pane lookup for openLiveInput. +type liveInputPaneMsg struct { + seq int + projectPath string + pane tmux.Pane + ok bool +} + +func (a *App) openLiveInput(projectPath string, sessionID ...string) (tea.Model, tea.Cmd) { + if !tmux.InTmux() { + a.copiedMsg = "Requires tmux" + return a, nil + } + a.liveInputSeq++ + seq := a.liveInputSeq + return a, func() tea.Msg { + pane, found := findPane(projectPath, sessionID...) + return liveInputPaneMsg{seq: seq, projectPath: projectPath, pane: pane, ok: found && paneHasClaude(pane.PID)} + } +} + +func (a *App) handleLiveInputPane(msg liveInputPaneMsg) tea.Cmd { + // A newer request superseded this one; opening an input for the older + // target would send text to the wrong pane. + if msg.seq != a.liveInputSeq { + return nil + } + if !msg.ok { + a.copiedMsg = "No live Claude pane" + return nil + } + pane, projectPath := msg.pane, msg.projectPath + a.liveInputPane = pane + a.liveInputProjDir = projectPath + + // If user prefers $EDITOR, skip inline modal and open editor directly + if a.editorInput { + return func() tea.Msg { + tmpFile, err := os.CreateTemp("", "ccx-input-*.md") + if err != nil { + return liveInputSentMsg{err: err} + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" + } + cmd := fmt.Sprintf("cd %s && %s %s", shellescape(projectPath), editor, tmpFile.Name()) + exec.Command("tmux", "display-popup", "-E", "-w", "80%", "-h", "70%", cmd).Run() + + content, readErr := os.ReadFile(tmpFile.Name()) + if readErr != nil || len(strings.TrimSpace(string(content))) == 0 { + return liveInputSentMsg{err: fmt.Errorf("empty")} + } + sendText := strings.TrimRight(string(content), "\n") + return liveInputSentMsg{err: tmux.SendKeys(pane, sendText)} + } + } + + a.liveInputModal = newInputModal() + a.liveInputActive = true + return nil +} diff --git a/internal/tui/pane_actions_test.go b/internal/tui/pane_actions_test.go new file mode 100644 index 0000000..c0c9eee --- /dev/null +++ b/internal/tui/pane_actions_test.go @@ -0,0 +1,141 @@ +package tui + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" +) + +// stubTmux replaces the tmux indirections and counts calls, so a test can +// prove nothing touches tmux on the Update path. +func stubTmux(t *testing.T, found bool, sendErr error) (finds, sends *int) { + t.Helper() + finds, sends = new(int), new(int) + origFind, origHas, origSend := findPane, paneHasClaude, sendInterruptK + t.Cleanup(func() { findPane, paneHasClaude, sendInterruptK = origFind, origHas, origSend }) + findPane = func(string, ...string) (tmux.Pane, bool) { + *finds++ + return tmux.Pane{Session: "s", Window: "1", Pane: "0", PID: 42}, found + } + paneHasClaude = func(int) bool { return true } + sendInterruptK = func(string) error { + *sends++ + return sendErr + } + return finds, sends +} + +func TestKillLiveSessionRunsTmuxOffUpdateAndReportsSendError(t *testing.T) { + finds, sends := stubTmux(t, true, errors.New("no server running")) + a := newTestApp(fakeSessions()) + sess := a.sessions[0] + + _, cmd := a.killLiveSession(sess) + if *finds != 0 || *sends != 0 { + t.Fatalf("tmux touched on Update path: finds=%d sends=%d", *finds, *sends) + } + if cmd == nil { + t.Fatal("expected a kill command") + } + a.Update(cmd()) + if *sends != 2 { + t.Fatalf("sends = %d, want 2 (Ctrl-C twice)", *sends) + } + if !strings.Contains(a.copiedMsg, "Kill failed") || !strings.Contains(a.copiedMsg, "no server running") { + t.Fatalf("send-keys error not surfaced: %q", a.copiedMsg) + } +} + +func TestBulkKillCountsOnlySuccessfulKills(t *testing.T) { + stubTmux(t, true, nil) + a := newTestApp(fakeSessions()) + live := []session.Session{a.sessions[0], a.sessions[1]} + for i := range live { + live[i].IsLive = true + } + _, cmd := a.bulkKill(live) + a.Update(cmd()) + if a.copiedMsg != "Killed 2" { + t.Fatalf("copiedMsg = %q, want %q", a.copiedMsg, "Killed 2") + } + + stubTmux(t, true, errors.New("boom")) + _, cmd = a.bulkKill(live) + a.Update(cmd()) + if !strings.HasPrefix(a.copiedMsg, "Killed 0, 2 failed") { + t.Fatalf("copiedMsg = %q, want failure count", a.copiedMsg) + } +} + +func TestOpenLiveInputLooksUpPaneOffUpdateAndDropsStale(t *testing.T) { + t.Setenv("TMUX", "/tmp/fake,1,0") + finds, _ := stubTmux(t, true, nil) + a := newTestApp(fakeSessions()) + a.editorInput = false + + _, first := a.openLiveInput("/p", "id1") + _, second := a.openLiveInput("/p", "id2") + if *finds != 0 { + t.Fatalf("FindPane ran on Update path %d time(s)", *finds) + } + a.Update(first()) + if a.liveInputActive { + t.Fatal("stale lookup result opened the input modal") + } + a.Update(second()) + if !a.liveInputActive { + t.Fatal("latest lookup result should open the input modal") + } +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, args := range [][]string{ + {"init", "-q", "-b", "main"}, + {"-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init"}, + } { + if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return dir +} + +// `git worktree add` must not run inside Update: it can take seconds on a +// large repo and froze the UI for that long. +func TestWorktreeNewRunsGitOffUpdate(t *testing.T) { + t.Setenv("TMUX", "") // never spawn a real tmux window from a test + repo, err := filepath.EvalSymlinks(initGitRepo(t)) // git reports the resolved path + if err != nil { + t.Fatal(err) + } + a := newTestApp(fakeSessions()) + sel := a.sessions[0] + sel.ProjectPath = repo + + _, cmd := a.executeNewWorktreeSession(sel, "feat-x") + wtPath := filepath.Join(repo, a.config.WorktreeDir, "feat-x") + if _, err := os.Stat(wtPath); err == nil { + t.Fatal("worktree created synchronously inside Update") + } + if cmd == nil { + t.Fatal("expected a worktree command") + } + msg, ok := cmd().(worktreeDoneMsg) + if !ok { + t.Fatalf("cmd returned %T", msg) + } + if _, err := os.Stat(wtPath); err != nil { + t.Fatalf("worktree not created by the command: %v (status %q)", err, msg.status) + } + if msg.execDir != wtPath { + t.Fatalf("non-tmux path should take over the terminal in %q, got %q", wtPath, msg.execDir) + } +} diff --git a/internal/tui/session_split_layout_test.go b/internal/tui/session_split_layout_test.go new file mode 100644 index 0000000..d18f57a --- /dev/null +++ b/internal/tui/session_split_layout_test.go @@ -0,0 +1,36 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// View must not write model state. renderSessionSplit used to resize the +// preview viewport while drawing; sizing now happens after Update. +func TestRenderSessionSplitDoesNotResizePreview(t *testing.T) { + a := newTestApp(fakeSessions()) + a.sessSplit.Show = true + a.Update(tea.WindowSizeMsg{Width: 160, Height: 50}) + w, h := a.sessSplit.Preview.Width, a.sessSplit.Preview.Height + if w == 0 || h == 0 { + t.Fatalf("Update did not size the preview: %dx%d", w, h) + } + + // Change the size behind Update's back: a pure View must not react. + a.width, a.height = 120, 40 + first := a.View() + if a.sessSplit.Preview.Width != w || a.sessSplit.Preview.Height != h { + t.Fatalf("View resized the preview to %dx%d (was %dx%d)", + a.sessSplit.Preview.Width, a.sessSplit.Preview.Height, w, h) + } + if second := a.View(); second != first { + t.Fatal("rendering twice produced different output") + } + + // The next Update applies the new size. + a.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if a.sessSplit.Preview.Width == w { + t.Fatal("Update did not apply the new preview width") + } +} diff --git a/internal/tui/worktree_async.go b/internal/tui/worktree_async.go new file mode 100644 index 0000000..756fa50 --- /dev/null +++ b/internal/tui/worktree_async.go @@ -0,0 +1,180 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/sendbird/ccx/internal/claudecmd" + "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" +) + +// `git worktree add` checks out a full tree and can take seconds on a large +// repo. It used to run inside Update, freezing input and redraw for that +// long. Each worktree action now does all git/tmux/filesystem work in a +// tea.Cmd and returns a worktreeDoneMsg that Update applies. + +// worktreeDoneMsg is the outcome of an async worktree action. +type worktreeDoneMsg struct { + status string // shown via copiedMsg + refresh bool // a new Claude window was spawned; poll for its session file + execDir string // non-tmux: take over the terminal with Claude in this dir + move *worktreeMove +} + +// worktreeMove describes sessions relocated from oldPath to newPath on disk; +// Update mirrors it into the in-memory session list. +type worktreeMove struct { + oldPath, newPath string +} + +func gitRepoRoot(dir string) (string, error) { + out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// addWorktree checks out ref at wtPath; if that fails (typically because ref +// is already checked out elsewhere, or does not exist yet) it creates +// newBranch at wtPath, starting from startPoint when given. +func addWorktree(repoRoot, wtPath, ref, newBranch, startPoint string) error { + if err := exec.Command("git", "-C", repoRoot, "worktree", "add", wtPath, ref).Run(); err == nil { + return nil + } + args := []string{"-C", repoRoot, "worktree", "add", "-b", newBranch, wtPath} + if startPoint != "" { + args = append(args, startPoint) + } + return exec.Command("git", args...).Run() +} + +// newWorktreeSessionCmd creates a worktree for branch and starts a new Claude +// session in it. okStatus is shown after a tmux spawn; execStatus before a +// non-tmux terminal takeover. Config values are captured by the caller so +// the goroutine never reads the model. +func newWorktreeSessionCmd(projectPath, worktreeDir, branch, windowName string, + claudeCfg claudecmd.Config, okStatus, spawnFailStatus, execStatus string) tea.Cmd { + return func() tea.Msg { + repoRoot, err := gitRepoRoot(projectPath) + if err != nil { + return worktreeDoneMsg{status: "Not a git repo"} + } + wtPath := filepath.Join(repoRoot, worktreeDir, branch) + if err := addWorktree(repoRoot, wtPath, branch, branch, ""); err != nil { + return worktreeDoneMsg{status: "Worktree failed: " + err.Error()} + } + if windowName == "" { + windowName = filepath.Base(repoRoot) + "/" + branch + } + if tmux.InTmux() { + if err := tmux.NewWindowClaudeNewWithConfig(windowName, wtPath, claudeCfg); err != nil { + return worktreeDoneMsg{status: spawnFailStatus} + } + return worktreeDoneMsg{status: okStatus, refresh: true} + } + return worktreeDoneMsg{status: execStatus, execDir: wtPath} + } +} + +// executeNewWorktreeSession creates a git worktree and spawns a new Claude session in it. +func (a *App) executeNewWorktreeSession(sess session.Session, branch string) (tea.Model, tea.Cmd) { + a.copiedMsg = "Creating worktree…" + return a, newWorktreeSessionCmd(sess.ProjectPath, a.config.WorktreeDir, branch, "", a.config.Claude, + fmt.Sprintf("New session → %s/%s", a.config.WorktreeDir, branch), "Spawn failed", "") +} + +// executeCmdWorktreeNew handles "worktree:new " / "wt:new ". +// It creates a git worktree for the given branch and opens a new Claude session in it. +func (a *App) executeCmdWorktreeNew(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: worktree:new " + return a, nil + } + branch := parts[len(parts)-1] + + sess, ok := a.selectedSession() + if !ok || sess.ProjectPath == "" { + a.copiedMsg = "No session with project selected" + return a, nil + } + a.copiedMsg = "Creating worktree…" + return a, newWorktreeSessionCmd(sess.ProjectPath, a.config.WorktreeDir, branch, branch, a.config.Claude, + fmt.Sprintf("Worktree + session → %s/%s", a.config.WorktreeDir, branch), + "Worktree created but spawn failed", + fmt.Sprintf("Worktree created → %s/%s", a.config.WorktreeDir, branch)) +} + +// executeWorktree moves the session's project into a new worktree named name. +func (a *App) executeWorktree(sess session.Session, name string) (tea.Model, tea.Cmd) { + projectPath, branch, worktreeDir := sess.ProjectPath, sess.GitBranch, a.config.WorktreeDir + a.copiedMsg = "Creating worktree…" + return a, func() tea.Msg { + repoRoot, err := gitRepoRoot(projectPath) + if err != nil { + return worktreeDoneMsg{status: "Not a git repo"} + } + if branch == "" { + bOut, err := exec.Command("git", "-C", projectPath, "branch", "--show-current").Output() + if err != nil { + return worktreeDoneMsg{status: "Cannot determine branch"} + } + branch = strings.TrimSpace(string(bOut)) + } + wtPath := filepath.Join(repoRoot, worktreeDir, name) + // Try the existing branch first; if it is already checked out, + // create a new branch from it. + if err := addWorktree(repoRoot, wtPath, branch, name, branch); err != nil { + return worktreeDoneMsg{status: "Worktree failed: " + err.Error()} + } + if err := session.MoveProject(projectPath, wtPath); err != nil { + return worktreeDoneMsg{status: "Move failed: " + err.Error()} + } + return worktreeDoneMsg{ + status: fmt.Sprintf("Worktree created → %s/%s", worktreeDir, name), + move: &worktreeMove{oldPath: projectPath, newPath: wtPath}, + } + } +} + +func (a *App) handleWorktreeDone(msg worktreeDoneMsg) tea.Cmd { + a.copiedMsg = msg.status + if mv := msg.move; mv != nil { + home, _ := os.UserHomeDir() + newName := session.ShortenPath(mv.newPath, home) + oldEncoded := session.EncodeProjectPath(mv.oldPath) + newEncoded := session.EncodeProjectPath(mv.newPath) + // Match by path, not index: the list may have been rescanned while + // the worktree was being created. + for i := range a.sessions { + if a.sessions[i].ProjectPath == mv.oldPath { + a.sessions[i].ProjectPath = mv.newPath + a.sessions[i].ProjectName = newName + a.sessions[i].IsWorktree = true + a.sessions[i].FilePath = strings.Replace(a.sessions[i].FilePath, oldEncoded, newEncoded, 1) + } + } + a.rebuildSessionList() + } + if msg.refresh { + return a.delayedRefreshCmd() + } + if msg.execDir != "" { + cmd, err := a.claudeCmd(msg.execDir) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return nil + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return tea.QuitMsg{} + }) + } + return nil +}