Skip to content

Commit 91c3280

Browse files
author
SqlRush
committed
feat(tui): live 'Working' status row + completed-turn separator
Port of the codex turn-progress feedback (screenshot-confirmed format): - StatusIndicatorWidget: while a turn runs the bottom pane shows '• Working (37m 16s • esc to interrupt)' above the composer — blinking bullet (600ms), shimmer sweep across 'Working' (shimmer.rs port: 2s gaussian-cosine band blending fg toward the detected terminal bg on truecolor, dim/plain/bold ladder otherwise), live elapsed via FmtElapsedCompact, 32ms tick loop driven by SpinnerTickMsg - turn lifecycle wiring: TurnStarted starts the row + tick; TurnComplete / TurnAborted / Error / StreamError clear it - Esc now interrupts the running turn (interrupt_turn default binding) when no overlay/popup/search owns the key - FinalMessageSeparator port: turns that ran exec/MCP work append a dim full-width rule on completion, labeled '─ Worked for 11m 41s ─…' when the turn exceeded 60s (duration from TurnComplete.DurationMs, falling back to the local timer); gated on had_work_activity like turn_runtime.rs The pre-existing RunningSpinner/FmtElapsedCompact ports are now actually wired. Locked by working_status_test.go (lifecycle, esc interrupt, separator gating/format); TUI suite + strict parity frames re-verified.
1 parent 7d5fabc commit 91c3280

9 files changed

Lines changed: 409 additions & 11 deletions

File tree

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.2
1+
0.2.3

‎internal/tui/chat.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tui
33
import (
44
"fmt"
55
"strings"
6+
"time"
67

78
tea "github.com/charmbracelet/bubbletea"
89

@@ -50,6 +51,12 @@ type ChatTranscript struct {
5051
// codex's display_lines_for_history_insert in app/resize_reflow.rs).
5152
scrollbackEmitted bool
5253

54+
// turnStartedAt anchors the elapsed time for the completed-turn separator;
55+
// hadWorkActivity gates the separator to turns that ran exec/tool calls
56+
// (port of turn_runtime.rs had_work_activity).
57+
turnStartedAt time.Time
58+
hadWorkActivity bool
59+
5360
// header retains the session-header seed (version/model/directory) supplied to
5461
// WithSessionHeader so a fresh session after /clear can re-seed the same
5562
// welcome card. seeded records whether a header was ever attached, so
@@ -177,6 +184,12 @@ func (t ChatTranscript) AppendUserMessage(text string) TranscriptView {
177184
// applyEvent dispatches on the event's message type.
178185
func (t ChatTranscript) applyEvent(ev protocol.Event) ChatTranscript {
179186
switch ev.Msg.Type {
187+
case protocol.EventMsgKindTurnStarted:
188+
// Anchor the turn duration and reset the work-activity flag that gates
189+
// the completed-turn separator (turn_runtime.rs had_work_activity).
190+
t.turnStartedAt = time.Now()
191+
t.hadWorkActivity = false
192+
180193
case protocol.EventMsgKindUserMessage:
181194
if ev.Msg.UserMessage != nil && strings.TrimSpace(ev.Msg.UserMessage.Message) != "" {
182195
t = t.commitStream()
@@ -217,6 +230,7 @@ func (t ChatTranscript) applyEvent(ev protocol.Event) ChatTranscript {
217230
t.cells = t.appendCell(cell)
218231
t.execIndex = cloneIndex(t.execIndex)
219232
t.execIndex[ev.Msg.ExecCommandBegin.CallID] = len(t.cells) - 1
233+
t.hadWorkActivity = true
220234
}
221235

222236
case protocol.EventMsgKindExecCommandOutputDelta:
@@ -236,6 +250,7 @@ func (t ChatTranscript) applyEvent(ev protocol.Event) ChatTranscript {
236250
t.cells = t.appendCell(NewToolCallCell(t.theme, title))
237251
t.toolIndex = cloneIndex(t.toolIndex)
238252
t.toolIndex[ev.Msg.McpToolCallBegin.CallID] = len(t.cells) - 1
253+
t.hadWorkActivity = true
239254
}
240255

241256
case protocol.EventMsgKindMcpToolCallEnd:
@@ -273,6 +288,20 @@ func (t ChatTranscript) applyEvent(ev protocol.Event) ChatTranscript {
273288

274289
case protocol.EventMsgKindTurnComplete, protocol.EventMsgKindTurnAborted:
275290
t = t.commitStream()
291+
// Completed-turn separator (FinalMessageSeparator): only after a turn
292+
// that performed real work (exec/tool calls), labeled with the elapsed
293+
// time when over a minute.
294+
if t.hadWorkActivity {
295+
elapsed := int64(0)
296+
if ev.Msg.Type == protocol.EventMsgKindTurnComplete &&
297+
ev.Msg.TurnComplete != nil && ev.Msg.TurnComplete.DurationMs != nil {
298+
elapsed = *ev.Msg.TurnComplete.DurationMs / 1000
299+
} else if !t.turnStartedAt.IsZero() {
300+
elapsed = int64(time.Since(t.turnStartedAt).Seconds())
301+
}
302+
t.cells = t.appendCell(NewWorkedForSeparatorCell(elapsed))
303+
}
304+
t.hadWorkActivity = false
276305
}
277306
return t
278307
}

‎internal/tui/chat_bottom.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package tui
33
import (
44
"fmt"
55
"strings"
6+
"time"
67
"unicode/utf8"
78

89
tea "github.com/charmbracelet/bubbletea"
910
"github.com/charmbracelet/lipgloss"
1011
"github.com/mattn/go-runewidth"
12+
13+
"github.com/sqlrush/codexgo/internal/protocol"
1114
)
1215

1316
// ChatBottomPane is the bottom-pane implementation of [BottomPane]. It wraps a
@@ -39,6 +42,15 @@ type ChatBottomPane struct {
3942
sender *AppEventSender
4043
// models is the /model picker entry list supplied by the host.
4144
models []ModelPickerEntry
45+
46+
// taskRunning tracks whether a turn is in flight; while true the pane
47+
// renders the live "Working (…)" status row above the composer (port of
48+
// StatusIndicatorWidget) and Esc interrupts the turn.
49+
taskRunning bool
50+
// taskStartedAt anchors the elapsed display.
51+
taskStartedAt time.Time
52+
// spinner is the working-status row state (blink frame + elapsed).
53+
spinner RunningSpinner
4254
}
4355

4456
// ChatBottomPaneConfig parameterizes a [ChatBottomPane].
@@ -86,6 +98,14 @@ func (p ChatBottomPane) Update(msg tea.Msg) (BottomPane, tea.Cmd) {
8698
case StatusMsg:
8799
p.status = string(m)
88100
return p, nil
101+
case CoreEventMsg:
102+
return p.handleCoreEvent(m)
103+
case SpinnerTickMsg:
104+
if !p.taskRunning {
105+
return p, nil // task ended; let the tick loop die
106+
}
107+
p.spinner = p.spinner.Tick().WithElapsed(time.Since(p.taskStartedAt))
108+
return p, SpinnerTickCmd()
89109
case OpenSlashOverlayEvent:
90110
return p.handleSlashOverlay(m)
91111
case ModelSelectedEvent:
@@ -98,11 +118,39 @@ func (p ChatBottomPane) Update(msg tea.Msg) (BottomPane, tea.Cmd) {
98118
p.overlays = stack
99119
return p, cmd
100120
}
121+
// Esc interrupts the running turn (codex's interrupt_turn default
122+
// binding) when no popup/search owns the key.
123+
if m.Type == tea.KeyEsc && p.taskRunning &&
124+
!p.composer.PopupVisible() && !p.composer.SearchActive() {
125+
return p, EventCmd(CodexOpEvent{Command: NewInterruptCommand()})
126+
}
101127
return p.handleKey(m)
102128
}
103129
return p, nil
104130
}
105131

132+
// handleCoreEvent tracks turn lifecycle events to drive the working-status row
133+
// (the Rust ChatWidget flips its StatusIndicatorWidget on TaskStarted /
134+
// TaskComplete; errors and aborts also clear it).
135+
func (p ChatBottomPane) handleCoreEvent(ev CoreEventMsg) (BottomPane, tea.Cmd) {
136+
switch ev.Event.Msg.Type {
137+
case protocol.EventMsgKindTurnStarted:
138+
p.taskRunning = true
139+
p.taskStartedAt = time.Now()
140+
p.spinner = NewRunningSpinner(true).WithTrueColor(p.colorLevel == ColorLevelTrueColor)
141+
p.composer = p.composer.SetTaskRunning(true)
142+
return p, SpinnerTickCmd()
143+
case protocol.EventMsgKindTurnComplete,
144+
protocol.EventMsgKindTurnAborted,
145+
protocol.EventMsgKindError,
146+
protocol.EventMsgKindStreamError:
147+
p.taskRunning = false
148+
p.composer = p.composer.SetTaskRunning(false)
149+
return p, nil
150+
}
151+
return p, nil
152+
}
153+
106154
// handleSlashOverlay opens the picker/overlay owned by a delegated slash
107155
// command. Commands without a wired overlay surface a status notice instead of
108156
// silently doing nothing.
@@ -205,6 +253,12 @@ func (p ChatBottomPane) View(area Rect) string {
205253
}
206254
var rows []string
207255

256+
// Live working-status row above the composer while a turn runs (port of
257+
// the StatusIndicatorWidget placement: status row → composer → footer).
258+
if p.taskRunning {
259+
rows = append(rows, p.spinner.Line(p.theme))
260+
}
261+
208262
if query, preview, active := p.composer.CurrentSearch(); active {
209263
rows = append(rows, p.renderSearchBar(query, preview, area.Width))
210264
} else {
@@ -455,6 +509,9 @@ func (p ChatBottomPane) DesiredHeight(width int) int {
455509
}
456510
// Composer block: top padding + textarea rows + bottom padding.
457511
h := textRows + 2
512+
if p.taskRunning {
513+
h++ // live working-status row above the composer
514+
}
458515

459516
if items, _, ok := p.composer.PopupRows(); ok {
460517
n := len(items)

‎internal/tui/history_cell.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,40 @@ func splitTrimmed(s string) []string {
450450
}
451451
return strings.Split(s, "\n")
452452
}
453+
454+
// WorkedForSeparatorCell is the completed-turn separator inserted after a turn
455+
// that performed real work (port of history_cell/separators.rs
456+
// FinalMessageSeparator, minus the runtime-metrics label which codexgo does
457+
// not collect): a full-width dim rule, labeled "─ Worked for <elapsed> ─" when
458+
// the turn ran longer than 60 seconds.
459+
type WorkedForSeparatorCell struct {
460+
// ElapsedSeconds is the turn duration; values <= 60 render an unlabeled rule.
461+
ElapsedSeconds int64
462+
}
463+
464+
// NewWorkedForSeparatorCell builds the separator for a completed turn.
465+
func NewWorkedForSeparatorCell(elapsedSeconds int64) WorkedForSeparatorCell {
466+
return WorkedForSeparatorCell{ElapsedSeconds: elapsedSeconds}
467+
}
468+
469+
// Lines implements HistoryCell.
470+
func (c WorkedForSeparatorCell) Lines(width int) []Line {
471+
if width <= 0 {
472+
return nil
473+
}
474+
dim := Style{Dim: true}
475+
if c.ElapsedSeconds <= 60 {
476+
return []Line{{Spans: []Span{StyledSpan(strings.Repeat("─", width), dim)}}}
477+
}
478+
label := fmt.Sprintf("─ Worked for %s ─", FmtElapsedCompact(c.ElapsedSeconds))
479+
labelRunes := []rune(label)
480+
if len(labelRunes) > width {
481+
labelRunes = labelRunes[:width]
482+
}
483+
fill := width - len(labelRunes)
484+
text := string(labelRunes)
485+
if fill > 0 {
486+
text += strings.Repeat("─", fill)
487+
}
488+
return []Line{{Spans: []Span{StyledSpan(text, dim)}}}
489+
}

‎internal/tui/shimmer.go‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package tui
2+
3+
// Shimmer text effect for the live "Working" status header — a port of
4+
// codex-rs/tui/src/shimmer.rs shimmer_spans: a gaussian-cosine band sweeps
5+
// across the characters on a 2-second period (synchronized to process start),
6+
// blending each character's foreground toward the terminal background at the
7+
// band's center. On non-truecolor terminals it falls back to a dim/plain/bold
8+
// intensity ladder (color_for_level).
9+
10+
import (
11+
"math"
12+
"strings"
13+
"time"
14+
15+
"github.com/charmbracelet/lipgloss"
16+
)
17+
18+
// shimmerEpoch anchors the sweep to process start, mirroring the Rust
19+
// OnceLock<Instant> elapsed_since_start.
20+
var shimmerEpoch = time.Now()
21+
22+
// shimmer tuning constants (verbatim from shimmer.rs).
23+
const (
24+
shimmerPadding = 10
25+
shimmerSweepSeconds = 2.0
26+
shimmerBandHalfWidth = 5.0
27+
shimmerHighlightMix = 0.9
28+
)
29+
30+
// ShimmerRender renders text with the moving-highlight effect. fg is the base
31+
// text color and bg the highlight target (the detected terminal background);
32+
// hasTrueColor selects the RGB blend path versus the modifier ladder.
33+
func ShimmerRender(text string, fg, bg RGB, hasTrueColor bool) string {
34+
chars := []rune(text)
35+
if len(chars) == 0 {
36+
return ""
37+
}
38+
39+
period := float64(len(chars) + shimmerPadding*2)
40+
elapsed := time.Since(shimmerEpoch).Seconds()
41+
pos := math.Mod(elapsed, shimmerSweepSeconds) / shimmerSweepSeconds * period
42+
43+
var b strings.Builder
44+
for i, ch := range chars {
45+
dist := math.Abs(float64(i+shimmerPadding) - pos)
46+
t := 0.0
47+
if dist <= shimmerBandHalfWidth {
48+
x := math.Pi * (dist / shimmerBandHalfWidth)
49+
t = 0.5 * (1.0 + math.Cos(x))
50+
}
51+
b.WriteString(shimmerCharStyle(t, fg, bg, hasTrueColor).Render(string(ch)))
52+
}
53+
return b.String()
54+
}
55+
56+
// shimmerCharStyle resolves the style for one character at band intensity t.
57+
func shimmerCharStyle(t float64, fg, bg RGB, hasTrueColor bool) lipgloss.Style {
58+
if hasTrueColor {
59+
blended := Blend(bg, fg, clamp01(t)*shimmerHighlightMix)
60+
return lipgloss.NewStyle().
61+
Renderer(trueColorRenderer).
62+
Foreground(lipgloss.Color(blended.Hex())).
63+
Bold(true)
64+
}
65+
// color_for_level fallback: dim below 0.2, plain to 0.6, bold above.
66+
switch {
67+
case t < 0.2:
68+
return lipgloss.NewStyle().Faint(true)
69+
case t < 0.6:
70+
return lipgloss.NewStyle()
71+
default:
72+
return lipgloss.NewStyle().Bold(true)
73+
}
74+
}
75+
76+
func clamp01(v float64) float64 {
77+
if v < 0 {
78+
return 0
79+
}
80+
if v > 1 {
81+
return 1
82+
}
83+
return v
84+
}

‎internal/tui/status_render.go‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"strings"
66
"time"
77

8+
tea "github.com/charmbracelet/bubbletea"
9+
810
"github.com/sqlrush/codexgo/internal/protocol"
911
)
1012

@@ -239,6 +241,24 @@ type RunningSpinner struct {
239241
InlineMessage string
240242
// frame advances the blink animation.
241243
frame int
244+
// trueColor enables the RGB shimmer path on the header.
245+
trueColor bool
246+
}
247+
248+
// WithTrueColor returns a copy with the RGB shimmer path toggled.
249+
func (s RunningSpinner) WithTrueColor(on bool) RunningSpinner {
250+
s.trueColor = on
251+
return s
252+
}
253+
254+
// SpinnerTickMsg drives the working-status animation; the bottom pane
255+
// reschedules it via SpinnerTickCmd while a task is running.
256+
type SpinnerTickMsg struct{}
257+
258+
// SpinnerTickCmd schedules the next animation frame (32ms cadence, matching
259+
// the Rust schedule_frame_in).
260+
func SpinnerTickCmd() tea.Cmd {
261+
return tea.Tick(SpinnerTickInterval, func(time.Time) tea.Msg { return SpinnerTickMsg{} })
242262
}
243263

244264
// NewRunningSpinner returns a spinner with upstream defaults (port of
@@ -297,7 +317,13 @@ func (s RunningSpinner) Line(theme Theme) string {
297317
b.WriteString(lipFg(theme.Primary).Render(ind))
298318
b.WriteString(" ")
299319
}
300-
b.WriteString(lipFg(theme.Primary).Render(s.Header))
320+
// The header carries codex's shimmer sweep (shimmer.rs): highlight toward
321+
// the detected terminal background on truecolor, modifier ladder otherwise.
322+
shimmerBg := theme.TerminalBgRGB
323+
if !theme.HasTerminalBg {
324+
shimmerBg = RGB{255, 255, 255}
325+
}
326+
b.WriteString(ShimmerRender(s.Header, theme.ForegroundRGB, shimmerBg, s.trueColor && theme.HasTerminalBg))
301327
b.WriteString(" ")
302328

303329
pretty := FmtElapsedCompact(int64(s.Elapsed.Seconds()))

0 commit comments

Comments
 (0)