Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 53 additions & 283 deletions internal/tui/app.go

Large diffs are not rendered by default.

114 changes: 81 additions & 33 deletions internal/tui/inputmodal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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++ {
Expand Down
82 changes: 82 additions & 0 deletions internal/tui/inputmodal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
59 changes: 59 additions & 0 deletions internal/tui/kitty_watch.go
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 50 additions & 0 deletions internal/tui/kitty_watch_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
4 changes: 4 additions & 0 deletions internal/tui/outputs_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading