Skip to content

Commit 7145829

Browse files
jkyberneeesclaude
andcommitted
feat: polish pass + @-reference file attachments
Make bodek feel premium and fluent, and add file/session attachments by reusing odek serve's resource index — no new agent logic. Fluency & polish: - smooth braille spinner; live elapsed timer and cycling status verbs while the agent reasons - per-tool glyphs in the activity feed (shell, read, search, browser…) - gradient hairline under the header (cached per width) - smart autoscroll: follows the stream while busy, never yanks you when scrolled up reading history; scroll-position indicator in the footer File attachments (@-references): - internal/client: Resources() queries odek's /api/resources endpoint - internal/tui: live @-completion popup (↑/↓ select, ⏎/⇥ insert, esc cancel) for files and sessions; dynamic relayout reserves its space - references resolve server-side via odek's resource registry, going through the same untrusted-content boundary as any external input Tests: resource-event decode, ref parsing (activeRef/refStart), tool glyphs, and a no-TTY smoke test driving a full streaming turn plus the approval and autocomplete panels at full and narrow widths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf
1 parent 64fa85e commit 7145829

9 files changed

Lines changed: 599 additions & 34 deletions

File tree

README.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,32 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
7777
| Key | Action |
7878
|-----|--------|
7979
| `` | Send the prompt |
80+
| `@` | Open file/session reference completion (see below) |
8081
| `^J` | Insert a newline in the input |
8182
| `^T` | Toggle extended thinking for the next turn |
8283
| `^L` | Clear the conversation |
8384
| `PgUp` / `PgDn` / wheel | Scroll the transcript |
8485
| `^C` | Quit |
8586

87+
### File attachments / `@` references
88+
89+
Type `@` in the input to attach context. bodek queries odek's resource index
90+
live and shows a completion popup; ``/`` to choose, `` or `` to insert,
91+
`esc` to dismiss.
92+
93+
| Reference | Resolves to |
94+
|-----------|-------------|
95+
| `@path/to/file` | The file's contents, inlined into your prompt |
96+
| `@sess:<id>` | A saved session transcript |
97+
98+
```
99+
> summarize @internal/client/client.go and compare it with @sess:20260618-ab12
100+
```
101+
102+
odek resolves and inlines the referenced content **server-side** (wrapped in
103+
its untrusted-content boundary), so attachments go through the same security
104+
model as any other external input — bodek doesn't special-case them.
105+
86106
When the agent requests approval for a dangerous operation, answer inline:
87107

88108
| Key | Action |
@@ -96,12 +116,17 @@ When the agent requests approval for a dangerous operation, answer inline:
96116
## What you see
97117

98118
- **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)).
99-
- **Tool activity** — every `tool_call`/`tool_result` shown live with a spinner,
100-
argument preview, and a one-line result.
119+
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
120+
per tool, a spinner, an argument preview, and a one-line result.
101121
- **Security approvals** — odek's `danger` engine prompts surface as an inline
102122
panel; your answer is sent straight back over the socket.
103-
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text.
123+
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text,
124+
with a running elapsed timer and cycling status while it works.
125+
- **`@` autocomplete** — a live, navigable popup of files and sessions.
104126
- **Telemetry** — session token totals and last-turn latency in the chrome.
127+
- **Fluent by default** — gradient wordmark and hairline, smooth braille
128+
spinner, smart autoscroll that never yanks you while you read history, and a
129+
scroll-position indicator.
105130
- **Engine notices** — skill loads, memory merges, and agent signals appear as
106131
quiet status lines.
107132

internal/client/client.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ package client
1010
import (
1111
"encoding/json"
1212
"fmt"
13+
"net/http"
14+
"net/url"
15+
"time"
1316

1417
ws "golang.org/x/net/websocket"
1518
)
@@ -64,16 +67,27 @@ type Event struct {
6467
// socket closes, so the TUI can react instead of hanging.
6568
const EventDisconnected = "_disconnected"
6669

70+
// Resource is a single @-reference completion candidate from /api/resources.
71+
type Resource struct {
72+
ID string `json:"id"` // full reference, e.g. "@src/main.go"
73+
Type string `json:"type"` // "file" | "session" | "skill"
74+
Label string `json:"label"` // display label
75+
Detail string `json:"detail"` // one-line description
76+
}
77+
6778
// Client is a connected odek serve session.
6879
type Client struct {
69-
conn *ws.Conn
70-
Events chan Event
80+
conn *ws.Conn
81+
baseURL string
82+
http *http.Client
83+
Events chan Event
7184
}
7285

7386
// Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint,
74-
// origin is an http://localhost-based origin accepted by the server, and token
75-
// is the per-instance CSRF token (obtained from a GET / Set-Cookie header).
76-
func Dial(wsURL, origin, token string) (*Client, error) {
87+
// origin is an http://localhost-based origin accepted by the server, baseURL is
88+
// the http:// root (used for the resource-search API), and token is the
89+
// per-instance CSRF token (obtained from a GET / Set-Cookie header).
90+
func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
7791
cfg, err := ws.NewConfig(wsURL, origin)
7892
if err != nil {
7993
return nil, fmt.Errorf("ws config: %w", err)
@@ -85,11 +99,35 @@ func Dial(wsURL, origin, token string) (*Client, error) {
8599
return nil, fmt.Errorf("ws dial: %w", err)
86100
}
87101

88-
c := &Client{conn: conn, Events: make(chan Event, 256)}
102+
c := &Client{
103+
conn: conn,
104+
baseURL: baseURL,
105+
http: &http.Client{Timeout: 3 * time.Second},
106+
Events: make(chan Event, 256),
107+
}
89108
go c.readLoop()
90109
return c, nil
91110
}
92111

112+
// Resources queries the server's @-reference completion endpoint.
113+
func (c *Client) Resources(query string, limit int) ([]Resource, error) {
114+
u := fmt.Sprintf("%s/api/resources?q=%s&limit=%d",
115+
c.baseURL, url.QueryEscape(query), limit)
116+
resp, err := c.http.Get(u)
117+
if err != nil {
118+
return nil, err
119+
}
120+
defer resp.Body.Close()
121+
if resp.StatusCode != http.StatusOK {
122+
return nil, fmt.Errorf("resources: status %s", resp.Status)
123+
}
124+
var out []Resource
125+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
126+
return nil, err
127+
}
128+
return out, nil
129+
}
130+
93131
// readLoop decodes frames into Events until the socket closes.
94132
func (c *Client) readLoop() {
95133
defer close(c.Events)

internal/tui/banner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func welcome(th theme, width int) string {
3535

3636
tips := [][2]string{
3737
{"type a task", "and press enter to run the agent"},
38+
{"@ to attach", "reference files & sessions, e.g. @main.go"},
3839
{"⏎ send", "· ^J newline · ^T toggle thinking"},
3940
{"^L clear", "· PgUp/PgDn scroll · ^C quit"},
4041
{"approvals", "answer with [a]pprove [d]eny [t]rust"},

internal/tui/helpers_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,44 @@ func TestCollapse(t *testing.T) {
4848
t.Errorf("collapse: got %q", got)
4949
}
5050
}
51+
52+
func TestActiveRef(t *testing.T) {
53+
cases := []struct {
54+
in string
55+
want string
56+
match bool
57+
}{
58+
{"explain @main", "main", true},
59+
{"@", "", true},
60+
{"look at @src/app.go", "src/app.go", true},
61+
{"no ref here", "", false},
62+
{"email a@b.com", "", false}, // '@' not at a token boundary
63+
{"@sess:abc done", "", false}, // ref already terminated by space
64+
}
65+
for _, c := range cases {
66+
got, ok := activeRef(c.in)
67+
if ok != c.match || (ok && got != c.want) {
68+
t.Errorf("activeRef(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.match)
69+
}
70+
}
71+
}
72+
73+
func TestRefStart(t *testing.T) {
74+
in := "please read @internal/x"
75+
idx, ok := refStart(in)
76+
if !ok || in[idx] != '@' {
77+
t.Fatalf("refStart(%q) = %d,%v (char %q)", in, idx, ok, in[idx])
78+
}
79+
if in[:idx] != "please read " {
80+
t.Errorf("prefix = %q", in[:idx])
81+
}
82+
}
83+
84+
func TestToolGlyph(t *testing.T) {
85+
if toolGlyph("shell") == toolGlyph("read_file") {
86+
t.Error("expected distinct glyphs for shell and read_file")
87+
}
88+
if toolGlyph("totally_unknown_tool") != "✦" {
89+
t.Errorf("unknown tool glyph = %q", toolGlyph("totally_unknown_tool"))
90+
}
91+
}

internal/tui/icons.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package tui
2+
3+
import "strings"
4+
5+
// toolGlyph returns a tasteful monochrome glyph for a tool, so the activity
6+
// feed reads at a glance. Matching is by substring to cover odek's native
7+
// tools, MCP tools (server__tool), and sub-agent variants.
8+
func toolGlyph(name string) string {
9+
n := strings.ToLower(name)
10+
switch {
11+
case strings.Contains(n, "shell"), strings.Contains(n, "bash"), strings.Contains(n, "exec"):
12+
return "❯"
13+
case strings.Contains(n, "write"), strings.Contains(n, "patch"), strings.Contains(n, "edit"):
14+
return "✎"
15+
case strings.Contains(n, "read"):
16+
return "◰"
17+
case strings.Contains(n, "list"), strings.Contains(n, "dir"), strings.Contains(n, "ls"):
18+
return "▤"
19+
case strings.Contains(n, "search"), strings.Contains(n, "grep"), strings.Contains(n, "find"):
20+
return "⌕"
21+
case strings.Contains(n, "browser"), strings.Contains(n, "http"), strings.Contains(n, "fetch"), strings.Contains(n, "web"):
22+
return "◉"
23+
case strings.Contains(n, "delegate"), strings.Contains(n, "subagent"), strings.Contains(n, "task"):
24+
return "⑂"
25+
case strings.Contains(n, "memory"), strings.Contains(n, "recall"):
26+
return "❖"
27+
case strings.Contains(n, "vision"), strings.Contains(n, "image"), strings.Contains(n, "transcribe"):
28+
return "◎"
29+
default:
30+
return "✦"
31+
}
32+
}
33+
34+
// resourceGlyph returns a glyph for an @-reference result type.
35+
func resourceGlyph(typ string) string {
36+
switch typ {
37+
case "session":
38+
return "⟳"
39+
case "skill":
40+
return "✦"
41+
default: // file
42+
return "≡"
43+
}
44+
}

0 commit comments

Comments
 (0)