Skip to content

Commit 76b8799

Browse files
committed
fix(tui): polish pass on telemetry, layout, rendering, and glyphs
- /clear and ctrl+l now reset session telemetry (turns, tools, tokens, age) so /stats and the header gauge start fresh - keep the viewport height stable when the taller approval panel opens/closes, so the footer never jumps - cache the finalized transcript prefix; re-render only the streaming tail on spinner ticks (invalidate on finalize, resize, resume, clear) - busy refresh no longer yanks the reader out of scrollback - gauge fill glyphs now switch on the same 75%/90% bands as its colors - replace emoji sandbox badge with width-stable monochrome glyphs - live elapsed badge ticks in whole seconds (tenths flickered at 12fps) - guard truncate() against zero/negative budgets on narrow terminals - human() promotes to M at the 999.5k rounding seam - restyle /help as a pre-styled brand card; tint the destructive session-delete hint red; brighten faint body text
1 parent ff6db5e commit 76b8799

12 files changed

Lines changed: 458 additions & 105 deletions

internal/tui/banner.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ func welcome(th theme, width int, cwd string) string {
4747
{"/ commands", "type / for commands, e.g. /help /sessions /model"},
4848
{"/stats", "session metrics & live context-window gauge"},
4949
{"@ to attach", "attach files, e.g. @main.go"},
50-
{"⏎ send", "· ^J newline · ^T toggle thinking"},
51-
{"^L clear", "· ↑/↓ scroll · PgUp/PgDn page · ^C quit"},
52-
{"approvals", "answer with [a]pprove [d]eny [t]rust"},
50+
{"⏎ send", "^J newline · ^T toggle thinking"},
51+
{"^L clear", "↑/↓ scroll · PgUp/PgDn page · ^C quit"},
52+
{"approvals", "a approve · d deny · t trust"},
5353
}
5454
const keyW = 11
5555
for _, t := range tips {

internal/tui/commands.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ func slashCommands() []command {
2727
return nil
2828
}},
2929
{"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd {
30-
m.msgs = nil
31-
m.curIdx = -1
32-
m.refresh()
30+
m.clearConversation()
3331
return nil
3432
}},
3533
{"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd {
@@ -147,18 +145,43 @@ func (m *Model) openCmdAC(query string) {
147145
m.refresh()
148146
}
149147

150-
// showHelp appends a rendered help card listing commands and key bindings.
148+
// showHelp appends a help card listing commands and key bindings. Like /stats
149+
// it is pre-styled to the brand palette (raw), not glamour's stock dark style.
151150
func (m *Model) showHelp() {
151+
th := m.th
152+
// Same box math as the /stats card: Width(boxW-2) renders boxW wide with a
153+
// boxW-4 content column the rules must match.
154+
boxW := max(min(m.vp.Width-2, 60), 28)
155+
innerW := boxW - 4
156+
rule := "\n" + th.rule.Render(strings.Repeat("─", innerW))
157+
152158
var b strings.Builder
153-
b.WriteString("### Commands\n\n")
159+
b.WriteString(th.acTitle.Render("⬡ help"))
160+
b.WriteString(rule)
161+
b.WriteString("\n" + th.statsLabel.Render("commands"))
162+
const cmdW = 10 // longest name is "/thinking"
154163
for _, c := range slashCommands() {
155-
b.WriteString(fmt.Sprintf("- `/%s` — %s\n", c.name, c.desc))
164+
b.WriteString("\n" + th.tipKey.Render(padRight("/"+c.name, cmdW)) + " " + th.tipText.Render(c.desc))
156165
}
157-
b.WriteString("\n### Keys\n\n")
158-
b.WriteString("`@` attach files/sessions · `^R` sessions · `^O` model · " +
159-
"`^T` thinking · `^J` newline · `Esc` cancel · `^L` clear · `^C` quit\n")
160-
content := b.String()
161-
m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)})
166+
b.WriteString(rule)
167+
b.WriteString("\n" + th.statsLabel.Render("keys"))
168+
const keyW = 4
169+
for _, k := range [][2]string{
170+
{"⏎", "send · run a /command"},
171+
{"^J", "newline in the input"},
172+
{"@", "attach files"},
173+
{"^R", "browse & resume sessions"},
174+
{"^O", "switch model"},
175+
{"^T", "toggle extended thinking"},
176+
{"^L", "clear the conversation"},
177+
{"esc", "cancel the running turn"},
178+
{"^C", "quit"},
179+
} {
180+
b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1]))
181+
}
182+
183+
card := th.acBox.Width(boxW - 2).Render(b.String())
184+
m.msgs = append(m.msgs, message{role: roleAsst, content: card, rendered: card, raw: true})
162185
m.refresh()
163186
}
164187

internal/tui/commands_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,16 @@ func TestSlashCommandsViaSubmit(t *testing.T) {
3636
t.Errorf("/clear left %d messages", len(m.msgs))
3737
}
3838

39-
// /help appends a rendered help card; input is reset.
39+
// /help appends a pre-styled help card; input is reset.
4040
m.ta.SetValue("/help")
4141
exec(m.submit())
42-
if len(m.msgs) == 0 || !strings.Contains(m.msgs[len(m.msgs)-1].content, "Commands") {
43-
t.Error("/help did not append a help card")
42+
if len(m.msgs) == 0 {
43+
t.Fatal("/help did not append a help card")
44+
}
45+
help := m.msgs[len(m.msgs)-1]
46+
if !help.raw || !strings.Contains(plain(help.content), "commands") ||
47+
!strings.Contains(plain(help.content), "/sessions") {
48+
t.Errorf("/help card malformed (raw=%v):\n%s", help.raw, plain(help.content))
4449
}
4550
if m.ta.Value() != "" {
4651
t.Errorf("input not reset after command: %q", m.ta.Value())

internal/tui/helpers_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func TestHuman(t *testing.T) {
2525
{0, "0"},
2626
{42, "42"},
2727
{1234, "1.2k"},
28+
{999_499, "999.5k"}, // just under the rounding seam
29+
{999_500, "1.0M"}, // one-decimal k rounding would reach 1000.0k → promote
2830
{2_500_000, "2.5M"},
2931
}
3032
for _, c := range cases {
@@ -41,6 +43,16 @@ func TestTruncate(t *testing.T) {
4143
if got := truncate("hello world", 5); got != "hell…" {
4244
t.Errorf("truncate: got %q", got)
4345
}
46+
// A zero/negative budget (very narrow terminal) must not panic.
47+
for _, n := range []int{0, -1, -8} {
48+
if got := truncate("hello", n); got != "" {
49+
t.Errorf("truncate(_, %d) = %q, want empty", n, got)
50+
}
51+
}
52+
// Budget of one leaves room for the ellipsis alone.
53+
if got := truncate("hello", 1); got != "…" {
54+
t.Errorf("truncate(_, 1) = %q, want ellipsis", got)
55+
}
4456
}
4557

4658
func TestCollapse(t *testing.T) {

internal/tui/integration_test.go

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tui
22

33
import (
44
"encoding/json"
5+
"errors"
56
"net/http"
67
"net/http/httptest"
78
"strings"
@@ -16,24 +17,62 @@ import (
1617

1718
// wired builds a Model backed by a live in-process odek-serve stand-in.
1819
func wired(t *testing.T) *Model {
20+
t.Helper()
21+
return standIn(t, "tok")
22+
}
23+
24+
// standIn builds a Model against an in-process odek-serve stand-in that
25+
// optionally enforces the per-instance WS token (cookie or X-Odek-Ws-Token
26+
// header) on /ws and /api/*, mirroring odek serve; an empty token disables
27+
// enforcement like odek versions that predate enforced auth.
28+
func standIn(t *testing.T, token string) *Model {
1929
t.Helper()
2030
t.Setenv("HOME", t.TempDir())
2131

22-
mux := http.NewServeMux()
23-
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {
24-
for {
25-
var d []byte
26-
if err := ws.Message.Receive(c, &d); err != nil {
32+
// serveTokenOK mirrors odek serve's validateServeToken: the token is
33+
// accepted via the odek_ws_token cookie or the X-Odek-Ws-Token header.
34+
serveTokenOK := func(r *http.Request) bool {
35+
if token == "" {
36+
return true
37+
}
38+
if c, err := r.Cookie("odek_ws_token"); err == nil && c.Value == token {
39+
return true
40+
}
41+
return r.Header.Get("X-Odek-Ws-Token") == token
42+
}
43+
guard := func(h http.HandlerFunc) http.HandlerFunc {
44+
return func(w http.ResponseWriter, r *http.Request) {
45+
if !serveTokenOK(r) {
46+
w.WriteHeader(http.StatusForbidden)
2747
return
2848
}
29-
_ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"})
30-
_ = ws.Message.Send(c, `{"type":"done","latency":1}`)
49+
h(w, r)
3150
}
32-
}))
33-
mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) {
34-
json.NewEncoder(w).Encode([]client.Session{{ID: "s1", Task: "first task", Turns: 1, UpdatedAt: time.Now()}})
51+
}
52+
53+
mux := http.NewServeMux()
54+
mux.Handle("/ws", ws.Server{
55+
Handshake: func(cfg *ws.Config, r *http.Request) error {
56+
if !serveTokenOK(r) {
57+
return errors.New("missing or invalid server token")
58+
}
59+
return nil
60+
},
61+
Handler: ws.Handler(func(c *ws.Conn) {
62+
for {
63+
var d []byte
64+
if err := ws.Message.Receive(c, &d); err != nil {
65+
return
66+
}
67+
_ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"})
68+
_ = ws.Message.Send(c, `{"type":"done","latency":1}`)
69+
}
70+
}),
3571
})
36-
mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) {
72+
mux.HandleFunc("/api/sessions", guard(func(w http.ResponseWriter, r *http.Request) {
73+
json.NewEncoder(w).Encode([]client.Session{{ID: "s1", Task: "first task", Turns: 1, UpdatedAt: time.Now()}})
74+
}))
75+
mux.HandleFunc("/api/sessions/", guard(func(w http.ResponseWriter, r *http.Request) {
3776
if r.Method == http.MethodDelete {
3877
w.WriteHeader(http.StatusNoContent)
3978
return
@@ -47,20 +86,20 @@ func wired(t *testing.T) *Model {
4786
{Role: "assistant", Content: ""}, // skipped (empty)
4887
},
4988
})
50-
})
51-
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
89+
}))
90+
mux.HandleFunc("/api/models", guard(func(w http.ResponseWriter, r *http.Request) {
5291
json.NewEncoder(w).Encode([]client.ModelInfo{{ID: "m1", Description: "one", Current: true}})
53-
})
54-
mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) {
92+
}))
93+
mux.HandleFunc("/api/resources", guard(func(w http.ResponseWriter, r *http.Request) {
5594
json.NewEncoder(w).Encode([]client.Resource{{ID: "@main.go", Type: "file", Label: "main.go", Detail: "1 KB"}})
56-
})
57-
mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) {
95+
}))
96+
mux.HandleFunc("/api/cancel", guard(func(w http.ResponseWriter, r *http.Request) {
5897
w.WriteHeader(http.StatusNoContent)
59-
})
98+
}))
6099

61100
srv := httptest.NewServer(mux)
62101
t.Cleanup(srv.Close)
63-
cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "tok")
102+
cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, token)
64103
if err != nil {
65104
t.Fatalf("Dial: %v", err)
66105
}
@@ -71,6 +110,16 @@ func wired(t *testing.T) *Model {
71110
return m
72111
}
73112

113+
func TestStandInWithoutTokenEnforcement(t *testing.T) {
114+
// Empty token = an odek that predates enforced auth: the stand-in serves
115+
// /ws and /api/* without any token checks.
116+
m := standIn(t, "")
117+
m.Update(exec(m.openModels()))
118+
if len(m.models) != 1 {
119+
t.Fatalf("models against an unenforced stand-in: %d", len(m.models))
120+
}
121+
}
122+
74123
func exec(cmd tea.Cmd) tea.Msg {
75124
if cmd == nil {
76125
return nil
@@ -357,4 +406,8 @@ func TestElapsed(t *testing.T) {
357406
if m.elapsed() == "" {
358407
t.Error("elapsed should be non-empty during a run")
359408
}
409+
// The live badge ticks in whole seconds (tenths would flicker at 12fps).
410+
if strings.Contains(m.elapsed(), ".") {
411+
t.Errorf("elapsed should not show tenths: %q", m.elapsed())
412+
}
360413
}

0 commit comments

Comments
 (0)