Skip to content

Commit ee68208

Browse files
jkyberneeesclaude
andcommitted
feat(tui): /command palette; scope @ to file attachments
Add standard slash-command support and a completion palette that mirrors the @ popup: - type "/" for a command palette (↑/↓ select, ⇥ complete, ⏎ run, esc): /help, /clear, /sessions, /model [name], /thinking [on|off], /cancel, /quit - a fully-typed "/cmd args" + ⏎ runs directly; commands work mid-turn (e.g. /cancel) and never get sent to the agent - /help renders a command + keybinding card Scope @ to attachments only: the completion now suggests files (sessions are reached via /sessions or ^R), with a relabeled popup and a command glyph. README, welcome tips, and key-binding docs updated. Fully covered by tests (commands.go 100%); suite + lint green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf
1 parent fee8bbe commit ee68208

8 files changed

Lines changed: 461 additions & 33 deletions

File tree

README.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
8181

8282
| Key | Action |
8383
|-----|--------|
84-
| `` | Send the prompt |
85-
| `@` | Open file/session reference completion (see below) |
84+
| `` | Send the prompt (or run a `/command`) |
85+
| `/` | Open the command palette (see below) |
86+
| `@` | Attach a file (see below) |
8687
| `^R` | Browse & resume saved sessions |
8788
| `^O` | Switch the model |
8889
| `^T` | Toggle extended thinking for the next turn |
@@ -92,24 +93,35 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
9293
| `PgUp` / `PgDn` / wheel | Scroll the transcript |
9394
| `^C` | Quit |
9495

95-
### File attachments / `@` references
96+
### Commands (`/`)
9697

97-
Type `@` in the input to attach context. bodek queries odek's resource index
98-
live and shows a completion popup; ``/`` to choose, `` or `` to insert,
99-
`esc` to dismiss.
98+
Type `/` at the start of the input for a command palette. ``/`` to choose,
99+
`` to complete, `` to run, `esc` to dismiss. You can also just type the full
100+
command and press ``.
100101

101-
| Reference | Resolves to |
102-
|-----------|-------------|
103-
| `@path/to/file` | The file's contents, inlined into your prompt |
104-
| `@sess:<id>` | A saved session transcript |
102+
| Command | Action |
103+
|---------|--------|
104+
| `/help` | List commands and key bindings |
105+
| `/clear` | Clear the conversation |
106+
| `/sessions` | Browse & resume saved sessions |
107+
| `/model [name]` | Switch model (opens a picker with no argument) |
108+
| `/thinking [on\|off]` | Toggle extended thinking |
109+
| `/cancel` | Cancel the running turn |
110+
| `/quit` | Exit bodek |
111+
112+
### File attachments (`@`)
113+
114+
Type `@` to attach a file. bodek searches the working tree and shows a
115+
completion popup; ``/`` to choose, `` or `` to insert, `esc` to dismiss.
105116

106117
```
107-
> summarize @internal/client/client.go and compare it with @sess:20260618-ab12
118+
> summarize @internal/client/client.go and explain the protocol
108119
```
109120

110-
odek resolves and inlines the referenced content **server-side** (wrapped in
111-
its untrusted-content boundary), so attachments go through the same security
112-
model as any other external input — bodek doesn't special-case them.
121+
odek resolves and inlines the file content **server-side** (wrapped in its
122+
untrusted-content boundary), so attachments go through the same security model
123+
as any other external input — bodek doesn't special-case them. (Saved sessions
124+
are resumed via `/sessions` or `^R`, not `@`.)
113125

114126
When the agent requests approval for a dangerous operation, answer inline:
115127

@@ -130,7 +142,7 @@ When the agent requests approval for a dangerous operation, answer inline:
130142
panel; your answer is sent straight back over the socket.
131143
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text,
132144
with a running elapsed timer and cycling status while it works.
133-
- **`@` autocomplete** — a live, navigable popup of files and sessions.
145+
- **Command palette (`/`)** and **file attachments (`@`)**live, navigable popups.
134146
- **Context-aware progress** — while the agent works, the status badge shows
135147
what it's actually doing (`🧪 running tests`, `📖 reading client.go`,
136148
`🚀 pushing`) with a live elapsed timer.

internal/tui/banner.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ 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"},
38+
{"/ commands", "type / for commands, e.g. /help /sessions /model"},
39+
{"@ to attach", "attach files, e.g. @main.go"},
3940
{"⏎ send", "· ^J newline · ^T toggle thinking"},
4041
{"^L clear", "· PgUp/PgDn scroll · ^C quit"},
4142
{"approvals", "answer with [a]pprove [d]eny [t]rust"},

internal/tui/commands.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
9+
"github.com/BackendStack21/bodek/internal/client"
10+
)
11+
12+
// command is a slash command typed in the input as "/name [args]".
13+
type command struct {
14+
name string
15+
desc string
16+
run func(m *Model, args string) tea.Cmd
17+
}
18+
19+
// slashCommands is the registry. Keeping it a function keeps the closures
20+
// simple and avoids package-init ordering concerns.
21+
func slashCommands() []command {
22+
return []command{
23+
{"help", "show available commands", func(m *Model, _ string) tea.Cmd {
24+
m.showHelp()
25+
return nil
26+
}},
27+
{"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd {
28+
m.msgs = nil
29+
m.curIdx = -1
30+
m.refresh()
31+
return nil
32+
}},
33+
{"sessions", "browse & resume saved sessions", func(m *Model, _ string) tea.Cmd {
34+
return m.openSessions()
35+
}},
36+
{"model", "switch model — /model [name]", func(m *Model, args string) tea.Cmd {
37+
if args != "" {
38+
m.pendModel = args
39+
m.model = args
40+
m.addNote("model set to " + args + " (applies next turn)")
41+
m.refresh()
42+
return nil
43+
}
44+
return m.openModels()
45+
}},
46+
{"thinking", "extended thinking — /thinking [on|off]", func(m *Model, args string) tea.Cmd {
47+
switch strings.ToLower(args) {
48+
case "on", "enabled", "true":
49+
m.thinkOn = true
50+
case "off", "disabled", "false":
51+
m.thinkOn = false
52+
default:
53+
m.thinkOn = !m.thinkOn
54+
}
55+
state := "off"
56+
if m.thinkOn {
57+
state = "on"
58+
}
59+
m.addNote("thinking " + state)
60+
m.refresh()
61+
return nil
62+
}},
63+
{"cancel", "cancel the running turn", func(m *Model, _ string) tea.Cmd {
64+
return m.cancelRun()
65+
}},
66+
{"quit", "exit bodek", func(m *Model, _ string) tea.Cmd {
67+
m.quitting = true
68+
return tea.Quit
69+
}},
70+
}
71+
}
72+
73+
// commandPrefix reports the command token when the input is a line-initial
74+
// slash command still being typed (a leading "/" with no whitespace yet).
75+
func commandPrefix(s string) (string, bool) {
76+
if !strings.HasPrefix(s, "/") {
77+
return "", false
78+
}
79+
body := s[1:]
80+
if strings.ContainsAny(body, " \t\n") {
81+
return "", false
82+
}
83+
return body, true
84+
}
85+
86+
// runCommandLine parses and dispatches a full "/name args" line.
87+
func (m *Model) runCommandLine(text string) tea.Cmd {
88+
body := strings.TrimPrefix(text, "/")
89+
name, args := body, ""
90+
if i := strings.IndexAny(body, " \t"); i >= 0 {
91+
name, args = body[:i], strings.TrimSpace(body[i+1:])
92+
}
93+
return m.runCommand(name, args)
94+
}
95+
96+
// runCommand finds and executes a command by name, resetting the input.
97+
func (m *Model) runCommand(name, args string) tea.Cmd {
98+
m.ta.Reset()
99+
m.closeAC()
100+
for _, c := range slashCommands() {
101+
if c.name == name {
102+
return c.run(m, args)
103+
}
104+
}
105+
m.addNote("unknown command: /" + name + " — try /help")
106+
m.refresh()
107+
return nil
108+
}
109+
110+
// runSelectedCommand executes the command highlighted in the popup.
111+
func (m *Model) runSelectedCommand() tea.Cmd {
112+
if len(m.ac.items) == 0 {
113+
m.closeAC()
114+
return nil
115+
}
116+
name := strings.TrimPrefix(m.ac.items[m.ac.sel].ID, "/")
117+
return m.runCommand(name, "")
118+
}
119+
120+
// openCmdAC populates the popup with commands matching the typed prefix.
121+
func (m *Model) openCmdAC(query string) {
122+
var items []client.Resource
123+
for _, c := range slashCommands() {
124+
if strings.HasPrefix(c.name, query) {
125+
items = append(items, client.Resource{
126+
ID: "/" + c.name, Type: "command", Label: "/" + c.name, Detail: c.desc,
127+
})
128+
}
129+
}
130+
m.ac.open = true
131+
m.ac.loading = false
132+
m.ac.mode = acCmd
133+
m.ac.query = query
134+
m.ac.items = items
135+
m.ac.seq++ // invalidate any in-flight @-search result
136+
if m.ac.sel >= len(items) {
137+
m.ac.sel = 0
138+
}
139+
m.relayout()
140+
m.refresh()
141+
}
142+
143+
// showHelp appends a rendered help card listing commands and key bindings.
144+
func (m *Model) showHelp() {
145+
var b strings.Builder
146+
b.WriteString("### Commands\n\n")
147+
for _, c := range slashCommands() {
148+
b.WriteString(fmt.Sprintf("- `/%s` — %s\n", c.name, c.desc))
149+
}
150+
b.WriteString("\n### Keys\n\n")
151+
b.WriteString("`@` attach files/sessions · `^R` sessions · `^O` model · " +
152+
"`^T` thinking · `^J` newline · `Esc` cancel · `^L` clear · `^C` quit\n")
153+
content := b.String()
154+
m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)})
155+
m.refresh()
156+
}

0 commit comments

Comments
 (0)