Skip to content

Commit 201618f

Browse files
idoubiclaude
andcommitted
feat: /btw mid-query input, plan mode enforcement, plugin management - v0.5.0
/btw & message queuing: - Users can now type during query execution (input visible while thinking) - Enter queues a message that auto-sends when current query completes - /btw <question> queues a side question - Queued message count shown in UI: "(2 queued)" - Multiple queued messages combined and sent as follow-up Plan mode enforcement: - /plan now actually blocks write tools (Edit, Write, Bash) via permission callback - Read-only tools (Read, Glob, Grep, WebFetch) still allowed in plan mode - Clear error message: "Plan mode: write operations blocked" Plugin management: - /plugin list - list all plugins - /plugin reload - reload plugins from disk - /plugin create <name> - scaffold new plugin with plugin.json 50 slash commands, 6000+ lines Go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c37dc78 commit 201618f

3 files changed

Lines changed: 114 additions & 7 deletions

File tree

internal/slash/commands.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,33 @@ func (h *Handler) testCmd(args []string) Result {
499499
// ─── /plugin ──────────────────────────────────────
500500

501501
func (h *Handler) pluginCmd(args []string) Result {
502-
allPlugins := plugins.LoadAll(config.GlobalConfigDir())
503-
return Result{Message: plugins.FormatPluginList(allPlugins)}
502+
configDir := config.GlobalConfigDir()
503+
504+
if len(args) == 0 {
505+
allPlugins := plugins.LoadAll(configDir)
506+
return Result{Message: plugins.FormatPluginList(allPlugins)}
507+
}
508+
509+
switch args[0] {
510+
case "list":
511+
allPlugins := plugins.LoadAll(configDir)
512+
return Result{Message: plugins.FormatPluginList(allPlugins)}
513+
case "reload":
514+
allPlugins := plugins.LoadAll(configDir)
515+
return Result{Message: fmt.Sprintf("✓ Reloaded %d plugins", len(allPlugins))}
516+
case "create":
517+
if len(args) < 2 {
518+
return Result{Message: "Usage: /plugin create <name>"}
519+
}
520+
name := args[1]
521+
dir := filepath.Join(configDir, "plugins", name)
522+
os.MkdirAll(dir, 0755)
523+
manifest := fmt.Sprintf(`{"name": "%s", "description": "", "version": "0.1.0"}`, name)
524+
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0644)
525+
return Result{Message: fmt.Sprintf("✓ Plugin %q created at %s\n\nAdd skills in %s/skills/<name>/SKILL.md", name, dir, dir)}
526+
default:
527+
return Result{Message: "Usage: /plugin [list|reload|create <name>]"}
528+
}
504529
}
505530

506531
// ─── /hooks ───────────────────────────────────────
@@ -1254,6 +1279,18 @@ func (h *Handler) worktreeCmd(args []string) Result {
12541279
}
12551280
}
12561281

1282+
// ─── /btw ─────────────────────────────────────────
1283+
1284+
func (h *Handler) btwCmd(args []string) Result {
1285+
if len(args) == 0 {
1286+
return Result{Message: "Usage: /btw <question>\n\nAsk a quick side question. If the agent is busy, it will be queued and answered next."}
1287+
}
1288+
question := strings.Join(args, " ")
1289+
return Result{
1290+
SkillPrompt: fmt.Sprintf("[Side question from user — answer briefly then continue previous task]: %s", question),
1291+
}
1292+
}
1293+
12571294
// ─── helpers ──────────────────────────────────────
12581295

12591296
func min(a, b int) int {

internal/slash/slash.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ func AllCommands() []CommandDef {
110110
// Multi-agent
111111
{Name: "/team", Description: "Team management (create, add, send, inbox)", HasArgs: true},
112112
{Name: "/worktree", Description: "Git worktree isolation (enter, exit)", HasArgs: true},
113+
// Mid-query
114+
{Name: "/btw", Description: "Side question (queued during execution)", HasArgs: true},
113115
}
114116
}
115117

@@ -257,6 +259,8 @@ func (h *Handler) Handle(input string) Result {
257259
return h.teamCmd(args)
258260
case "/worktree":
259261
return h.worktreeCmd(args)
262+
case "/btw":
263+
return h.btwCmd(args)
260264
default:
261265
// Try skill invocation
262266
if result, ok := h.HandleSkillInvocation(cmd, args); ok {

internal/tui/model.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ type Model struct {
127127
transcriptMode bool
128128
planMode bool // no-execution mode
129129
spinnerVerb string // current fun spinner verb
130+
131+
// Queued messages (typed during query execution)
132+
queuedMessages []string
130133
}
131134

132135
// DisplayBlock represents one visual block in the conversation
@@ -264,9 +267,15 @@ func (m *Model) createPermissionCallback() types.CanUseToolFn {
264267
}
265268
}
266269

267-
// plan mode: allow all tools (planning can read/analyze)
268-
if mode == types.PermissionModePlan {
269-
return allow, nil
270+
// plan mode: only allow read-only tools, block writes
271+
if mode == types.PermissionModePlan || m.planMode {
272+
if tool.IsReadOnly(input) {
273+
return allow, nil
274+
}
275+
return &types.PermissionDecision{
276+
Behavior: types.PermissionDeny,
277+
Reason: "Plan mode: write operations blocked. Exit plan mode with /plan to execute.",
278+
}, nil
270279
}
271280

272281
// default mode: auto-approve read-only tools, ask for write tools
@@ -402,6 +411,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
402411
m.input.Focus()
403412
// Ring terminal bell to notify user
404413
fmt.Print("\a")
414+
415+
// Process queued messages (typed during query via /btw or Enter)
416+
if len(m.queuedMessages) > 0 {
417+
combined := strings.Join(m.queuedMessages, "\n\nAlso: ")
418+
m.queuedMessages = nil
419+
return m, m.sendQuery(combined)
420+
}
405421
return m, nil
406422

407423
case permissionRequestMsg:
@@ -468,8 +484,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
468484
case statePermission:
469485
return m.handlePermissionKey(msg)
470486
case stateQuerying:
471-
// Allow scrolling while querying
472-
return m.handleScrollKey(msg)
487+
// Allow scrolling and typing during query
488+
return m.handleQueryingKey(msg)
473489
}
474490

475491
return m, nil
@@ -509,6 +525,50 @@ func (m *Model) handleScrollKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
509525
return m, nil
510526
}
511527

528+
func (m *Model) handleQueryingKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
529+
// Scroll keys
530+
switch msg.String() {
531+
case "up", "k":
532+
m.viewport.LineUp(1)
533+
m.userScrolled = true
534+
return m, nil
535+
case "down", "j":
536+
m.viewport.LineDown(1)
537+
if m.viewport.AtBottom() {
538+
m.userScrolled = false
539+
}
540+
return m, nil
541+
case "pgup", "ctrl+b":
542+
m.viewport.HalfViewUp()
543+
m.userScrolled = true
544+
return m, nil
545+
case "pgdown", "ctrl+f":
546+
m.viewport.HalfViewDown()
547+
if m.viewport.AtBottom() {
548+
m.userScrolled = false
549+
}
550+
return m, nil
551+
case "enter":
552+
// Queue a message typed during query (btw-style)
553+
text := m.input.Value()
554+
if text != "" {
555+
m.input.Reset()
556+
m.queuedMessages = append(m.queuedMessages, text)
557+
m.blocks = append(m.blocks, DisplayBlock{
558+
Type: "system",
559+
Content: fmt.Sprintf("📝 Queued: %s", text),
560+
Timestamp: time.Now(),
561+
})
562+
m.refreshViewport()
563+
}
564+
return m, nil
565+
}
566+
567+
// Let textarea handle regular typing (so user can compose during query)
568+
_, cmd := m.input.Update(msg)
569+
return m, cmd
570+
}
571+
512572
func (m *Model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
513573
// Scrolling keys work even in input mode
514574
switch msg.String() {
@@ -835,6 +895,12 @@ func (m *Model) View() string {
835895
b.WriteString(m.renderPermissionPrompt())
836896
case stateQuerying:
837897
b.WriteString(m.renderActivityLine())
898+
b.WriteString("\n")
899+
// Show input so user can type /btw or queue messages during query
900+
b.WriteString(m.input.View())
901+
if len(m.queuedMessages) > 0 {
902+
b.WriteString(theme.DimText.Render(fmt.Sprintf(" (%d queued)", len(m.queuedMessages))))
903+
}
838904
case stateInit:
839905
b.WriteString(fmt.Sprintf(" %s Initializing...", m.spinner.View()))
840906
default:

0 commit comments

Comments
 (0)