detached 210ef98

Make slash commands case-insensitive

commit
210ef9833cad0af33933661218f8236f6d3e5bf5
author
patriceckhart · · 2026-07-25 15:28 UTC
parent
34e0352
signature
unsigned
refs
tag: v0.3.8
15 files · +240 -66
M README.md +2 -2
@@ -261,7 +261,7 @@ Both interfaces share the same event schema, so transcripts captured by one can
261261
262262 ## Slash commands
263263
264-Type `/` in the TUI to open the autocomplete popup. Available commands:
264+Slash command names are case-insensitive in the TUI and messaging backends; arguments keep their original case. Type `/` in the TUI to open the autocomplete popup. Available commands:
265265
266266 | Command | Description |
267267 |---|---|
@@ -978,7 +978,7 @@ Setup flow:
978978 3. Run `zot telegram-bot run` in the directory you want the agent to operate in.
979979 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.
980980
981-From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600).
981+From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands are case-insensitive: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600).
982982
983983 Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active.
984984
@@ -261,7 +261,7 @@ Both interfaces share the same event schema, so transcripts captured by one can
261 261
262 ## Slash commands262 ## Slash commands
263 263
264-Type `/` in the TUI to open the autocomplete popup. Available commands:264+Slash command names are case-insensitive in the TUI and messaging backends; arguments keep their original case. Type `/` in the TUI to open the autocomplete popup. Available commands:
265 265
266 | Command | Description |266 | Command | Description |
267 |---|---|267 |---|---|
@@ -978,7 +978,7 @@ Setup flow:
978 3. Run `zot telegram-bot run` in the directory you want the agent to operate in.978 3. Run `zot telegram-bot run` in the directory you want the agent to operate in.
979 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.979 4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.
980 980
981-From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600).981+From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands are case-insensitive: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600).
982 982
983 Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active.983 Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active.
984 984
M docs/extensions.md +2 -0
@@ -185,6 +185,8 @@ other stdout frame before `hello`.
185185 "description":"current weather for a city"}
186186 ```
187187
188+Command names are matched case-insensitively. zot sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active.
189+
188190 #### `register_tool`
189191
190192 Registers a tool the LLM can call. `schema` is a JSON Schema object
@@ -185,6 +185,8 @@ other stdout frame before `hello`.
185 "description":"current weather for a city"}185 "description":"current weather for a city"}
186 ```186 ```
187 187
188+Command names are matched case-insensitively. zot sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active.
189+
188 #### `register_tool`190 #### `register_tool`
189 191
190 Registers a tool the LLM can call. `schema` is a JSON Schema object192 Registers a tool the LLM can call. `schema` is a JSON Schema object
M packages/agent/extensions/manager.go +28 -16
@@ -132,6 +132,11 @@ type HostHooks interface {
132132 ClosePanel(extName, panelID string)
133133 }
134134
135+type commandRegistration struct {
136+ ext *Extension
137+ name string
138+}
139+
135140 // Manager owns every extension subprocess for the lifetime of zot.
136141 type Manager struct {
137142 zotHome string
@@ -144,11 +149,10 @@ type Manager struct {
144149 mu sync.RWMutex
145150 ext map[string]*Extension // keyed by manifest name
146151
147- // commandIndex maps a slash-command name (without the leading /)
148- // to the extension that registered it. First-come-first-served:
149- // later registrations of the same command are dropped with a
150- // warning.
151- commandIndex map[string]*Extension
152+ // commandIndex maps a lower-cased slash-command name (without the
153+ // leading /) to its canonical registration. First-come-first-served:
154+ // later registrations that differ only by case are dropped with a warning.
155+ commandIndex map[string]commandRegistration
152156
153157 // toolIndex maps an extension-defined tool name to its owning
154158 // extension. Same first-come-first-served rule as commandIndex.
@@ -175,7 +179,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man
175179 model: model,
176180 hooks: hooks,
177181 ext: map[string]*Extension{},
178- commandIndex: map[string]*Extension{},
182+ commandIndex: map[string]commandRegistration{},
179183 toolIndex: map[string]*Extension{},
180184 }
181185 }
@@ -418,7 +422,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
418422 explicit := append([]string(nil), m.explicitPaths...)
419423 stats.Stopped = len(old)
420424 m.ext = map[string]*Extension{}
421- m.commandIndex = map[string]*Extension{}
425+ m.commandIndex = map[string]commandRegistration{}
422426 m.toolIndex = map[string]*Extension{}
423427 m.explicitPaths = nil
424428 callback := m.onReload
@@ -654,8 +658,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
654658 // future invocations don't dangle. The subprocess is gone; we
655659 // won't hear back about its commands or tool calls anymore.
656660 m.mu.Lock()
657- for name, owner := range m.commandIndex {
658- if owner == ext {
661+ for name, registration := range m.commandIndex {
662+ if registration.ext == ext {
659663 delete(m.commandIndex, name)
660664 }
661665 }
@@ -690,9 +694,10 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
690694 // keep the race detector happy.
691695 m.mu.Lock()
692696 ext.commands = append(ext.commands, rc)
693- _, shadowed := m.commandIndex[rc.Name]
697+ key := strings.ToLower(rc.Name)
698+ _, shadowed := m.commandIndex[key]
694699 if !shadowed {
695- m.commandIndex[rc.Name] = ext
700+ m.commandIndex[key] = commandRegistration{ext: ext, name: rc.Name}
696701 }
697702 m.mu.Unlock()
698703 if shadowed {
@@ -915,10 +920,11 @@ func (m *Manager) Diagnostics() []ExtensionDiagnostic {
915920 default:
916921 }
917922 for _, c := range ext.commands {
923+ registration, ok := m.commandIndex[strings.ToLower(c.Name)]
918924 diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{
919925 Name: c.Name,
920926 Description: c.Description,
921- Active: m.commandIndex[c.Name] == ext,
927+ Active: ok && registration.ext == ext && registration.name == c.Name,
922928 })
923929 }
924930 for _, t := range ext.tools {
@@ -941,6 +947,10 @@ func (m *Manager) Commands() []CommandInfo {
941947 var out []CommandInfo
942948 for _, ext := range m.ext {
943949 for _, c := range ext.commands {
950+ registration, ok := m.commandIndex[strings.ToLower(c.Name)]
951+ if !ok || registration.ext != ext || registration.name != c.Name {
952+ continue
953+ }
944954 out = append(out, CommandInfo{
945955 Extension: ext.Manifest.Name,
946956 Name: c.Name,
@@ -1048,15 +1058,15 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
10481058 func (m *Manager) HasCommand(name string) bool {
10491059 m.mu.RLock()
10501060 defer m.mu.RUnlock()
1051- _, ok := m.commandIndex[name]
1061+ _, ok := m.commandIndex[strings.ToLower(name)]
10521062 return ok
10531063 }
10541064
10551065 func (m *Manager) CommandOwner(name string) string {
10561066 m.mu.RLock()
10571067 defer m.mu.RUnlock()
1058- if ext, ok := m.commandIndex[name]; ok && ext != nil {
1059- return ext.Manifest.Name
1068+ if registration, ok := m.commandIndex[strings.ToLower(name)]; ok && registration.ext != nil {
1069+ return registration.ext.Manifest.Name
10601070 }
10611071 return ""
10621072 }
@@ -1067,11 +1077,13 @@ func (m *Manager) CommandOwner(name string) string {
10671077 // (prompt / insert / display).
10681078 func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {
10691079 m.mu.RLock()
1070- ext, ok := m.commandIndex[name]
1080+ registration, ok := m.commandIndex[strings.ToLower(name)]
10711081 m.mu.RUnlock()
10721082 if !ok {
10731083 return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)
10741084 }
1085+ ext := registration.ext
1086+ name = registration.name
10751087
10761088 id := newCorrelationID()
10771089 ch := make(chan extproto.CommandResponseFromExt, 1)
@@ -132,6 +132,11 @@ type HostHooks interface {
132 ClosePanel(extName, panelID string)132 ClosePanel(extName, panelID string)
133 }133 }
134 134
135+type commandRegistration struct {
136+ ext *Extension
137+ name string
138+}
139+
135 // Manager owns every extension subprocess for the lifetime of zot.140 // Manager owns every extension subprocess for the lifetime of zot.
136 type Manager struct {141 type Manager struct {
137 zotHome string142 zotHome string
@@ -144,11 +149,10 @@ type Manager struct {
144 mu sync.RWMutex149 mu sync.RWMutex
145 ext map[string]*Extension // keyed by manifest name150 ext map[string]*Extension // keyed by manifest name
146 151
147- // commandIndex maps a slash-command name (without the leading /)152+ // commandIndex maps a lower-cased slash-command name (without the
148- // to the extension that registered it. First-come-first-served:153+ // leading /) to its canonical registration. First-come-first-served:
149- // later registrations of the same command are dropped with a154+ // later registrations that differ only by case are dropped with a warning.
150- // warning.155+ commandIndex map[string]commandRegistration
151- commandIndex map[string]*Extension
152 156
153 // toolIndex maps an extension-defined tool name to its owning157 // toolIndex maps an extension-defined tool name to its owning
154 // extension. Same first-come-first-served rule as commandIndex.158 // extension. Same first-come-first-served rule as commandIndex.
@@ -175,7 +179,7 @@ func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Man
175 model: model,179 model: model,
176 hooks: hooks,180 hooks: hooks,
177 ext: map[string]*Extension{},181 ext: map[string]*Extension{},
178- commandIndex: map[string]*Extension{},182+ commandIndex: map[string]commandRegistration{},
179 toolIndex: map[string]*Extension{},183 toolIndex: map[string]*Extension{},
180 }184 }
181 }185 }
@@ -418,7 +422,7 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
418 explicit := append([]string(nil), m.explicitPaths...)422 explicit := append([]string(nil), m.explicitPaths...)
419 stats.Stopped = len(old)423 stats.Stopped = len(old)
420 m.ext = map[string]*Extension{}424 m.ext = map[string]*Extension{}
421- m.commandIndex = map[string]*Extension{}425+ m.commandIndex = map[string]commandRegistration{}
422 m.toolIndex = map[string]*Extension{}426 m.toolIndex = map[string]*Extension{}
423 m.explicitPaths = nil427 m.explicitPaths = nil
424 callback := m.onReload428 callback := m.onReload
@@ -654,8 +658,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
654 // future invocations don't dangle. The subprocess is gone; we658 // future invocations don't dangle. The subprocess is gone; we
655 // won't hear back about its commands or tool calls anymore.659 // won't hear back about its commands or tool calls anymore.
656 m.mu.Lock()660 m.mu.Lock()
657- for name, owner := range m.commandIndex {661+ for name, registration := range m.commandIndex {
658- if owner == ext {662+ if registration.ext == ext {
659 delete(m.commandIndex, name)663 delete(m.commandIndex, name)
660 }664 }
661 }665 }
@@ -690,9 +694,10 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
690 // keep the race detector happy.694 // keep the race detector happy.
691 m.mu.Lock()695 m.mu.Lock()
692 ext.commands = append(ext.commands, rc)696 ext.commands = append(ext.commands, rc)
693- _, shadowed := m.commandIndex[rc.Name]697+ key := strings.ToLower(rc.Name)
698+ _, shadowed := m.commandIndex[key]
694 if !shadowed {699 if !shadowed {
695- m.commandIndex[rc.Name] = ext700+ m.commandIndex[key] = commandRegistration{ext: ext, name: rc.Name}
696 }701 }
697 m.mu.Unlock()702 m.mu.Unlock()
698 if shadowed {703 if shadowed {
@@ -915,10 +920,11 @@ func (m *Manager) Diagnostics() []ExtensionDiagnostic {
915 default:920 default:
916 }921 }
917 for _, c := range ext.commands {922 for _, c := range ext.commands {
923+ registration, ok := m.commandIndex[strings.ToLower(c.Name)]
918 diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{924 diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{
919 Name: c.Name,925 Name: c.Name,
920 Description: c.Description,926 Description: c.Description,
921- Active: m.commandIndex[c.Name] == ext,927+ Active: ok && registration.ext == ext && registration.name == c.Name,
922 })928 })
923 }929 }
924 for _, t := range ext.tools {930 for _, t := range ext.tools {
@@ -941,6 +947,10 @@ func (m *Manager) Commands() []CommandInfo {
941 var out []CommandInfo947 var out []CommandInfo
942 for _, ext := range m.ext {948 for _, ext := range m.ext {
943 for _, c := range ext.commands {949 for _, c := range ext.commands {
950+ registration, ok := m.commandIndex[strings.ToLower(c.Name)]
951+ if !ok || registration.ext != ext || registration.name != c.Name {
952+ continue
953+ }
944 out = append(out, CommandInfo{954 out = append(out, CommandInfo{
945 Extension: ext.Manifest.Name,955 Extension: ext.Manifest.Name,
946 Name: c.Name,956 Name: c.Name,
@@ -1048,15 +1058,15 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1048 func (m *Manager) HasCommand(name string) bool {1058 func (m *Manager) HasCommand(name string) bool {
1049 m.mu.RLock()1059 m.mu.RLock()
1050 defer m.mu.RUnlock()1060 defer m.mu.RUnlock()
1051- _, ok := m.commandIndex[name]1061+ _, ok := m.commandIndex[strings.ToLower(name)]
1052 return ok1062 return ok
1053 }1063 }
1054 1064
1055 func (m *Manager) CommandOwner(name string) string {1065 func (m *Manager) CommandOwner(name string) string {
1056 m.mu.RLock()1066 m.mu.RLock()
1057 defer m.mu.RUnlock()1067 defer m.mu.RUnlock()
1058- if ext, ok := m.commandIndex[name]; ok && ext != nil {1068+ if registration, ok := m.commandIndex[strings.ToLower(name)]; ok && registration.ext != nil {
1059- return ext.Manifest.Name1069+ return registration.ext.Manifest.Name
1060 }1070 }
1061 return ""1071 return ""
1062 }1072 }
@@ -1067,11 +1077,13 @@ func (m *Manager) CommandOwner(name string) string {
1067 // (prompt / insert / display).1077 // (prompt / insert / display).
1068 func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {1078 func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {
1069 m.mu.RLock()1079 m.mu.RLock()
1070- ext, ok := m.commandIndex[name]1080+ registration, ok := m.commandIndex[strings.ToLower(name)]
1071 m.mu.RUnlock()1081 m.mu.RUnlock()
1072 if !ok {1082 if !ok {
1073 return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)1083 return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)
1074 }1084 }
1085+ ext := registration.ext
1086+ name = registration.name
1075 1087
1076 id := newCorrelationID()1088 id := newCorrelationID()
1077 ch := make(chan extproto.CommandResponseFromExt, 1)1089 ch := make(chan extproto.CommandResponseFromExt, 1)
M packages/agent/extensions/manager_test.go +35 -9
@@ -82,12 +82,19 @@ func writeMockExtension(t *testing.T, root string) {
8282 // the manager to send command_invoked.
8383 script := `#!/bin/sh
8484 printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
85-printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'
85+printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
8686 while IFS= read -r line; do
8787 case "$line" in
8888 *'"type":"command_invoked"'*)
8989 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
90- printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
90+ case "$line" in
91+ *'"name":"Ping"'*)
92+ printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
93+ ;;
94+ *)
95+ printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
96+ ;;
97+ esac
9198 ;;
9299 *'"type":"shutdown"'*)
93100 printf '%s\n' '{"type":"shutdown_ack"}'
@@ -168,19 +175,22 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
168175 cmds := mgr.Commands()
169176 found := false
170177 for _, c := range cmds {
171- if c.Name == "ping" {
178+ if c.Name == "Ping" {
172179 found = true
173180 break
174181 }
175182 }
176183 if !found {
177- t.Fatalf("expected command 'ping', got %#v", cmds)
184+ t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
178185 }
179- if !mgr.HasCommand("ping") {
180- t.Fatal("HasCommand(\"ping\") = false")
186+ if !mgr.HasCommand("PING") {
187+ t.Fatal("HasCommand(\"PING\") = false")
188+ }
189+ if owner := mgr.CommandOwner("pInG"); owner != "mock" {
190+ t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
181191 }
182192
183- resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)
193+ resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
184194 if err != nil {
185195 t.Fatalf("invoke: %v", err)
186196 }
@@ -215,7 +225,8 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
215225 }
216226
217227 writeDiagExtension("a-first", `#!/bin/sh
218-printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}'
228+printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
229+printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
219230 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
220231 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
221232 printf '%s\n' '{"type":"ready"}'
@@ -226,7 +237,8 @@ while IFS= read -r line; do
226237 done
227238 `)
228239 writeDiagExtension("b-second", `#!/bin/sh
229-printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}'
240+printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
241+printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
230242 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
231243 printf '%s\n' '{"type":"ready"}'
232244 while IFS= read -r line; do
@@ -255,6 +267,7 @@ done
255267 }
256268
257269 var shadowedTool bool
270+ var activeCommands, shadowedCommands int
258271 var conflictMessage bool
259272 for _, d := range diags {
260273 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
@@ -265,10 +278,23 @@ done
265278 shadowedTool = true
266279 }
267280 }
281+ for _, command := range d.Commands {
282+ if !strings.EqualFold(command.Name, "casetest") {
283+ continue
284+ }
285+ if command.Active {
286+ activeCommands++
287+ } else {
288+ shadowedCommands++
289+ }
290+ }
268291 }
269292 if !shadowedTool {
270293 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
271294 }
295+ if activeCommands != 1 || shadowedCommands != 1 {
296+ t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
297+ }
272298 if !conflictMessage {
273299 t.Fatalf("expected conflict diagnostic, got %#v", diags)
274300 }
@@ -82,12 +82,19 @@ func writeMockExtension(t *testing.T, root string) {
82 // the manager to send command_invoked.82 // the manager to send command_invoked.
83 script := `#!/bin/sh83 script := `#!/bin/sh
84 printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'84 printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
85-printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'85+printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
86 while IFS= read -r line; do86 while IFS= read -r line; do
87 case "$line" in87 case "$line" in
88 *'"type":"command_invoked"'*)88 *'"type":"command_invoked"'*)
89 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')89 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
90- printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"90+ case "$line" in
91+ *'"name":"Ping"'*)
92+ printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
93+ ;;
94+ *)
95+ printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
96+ ;;
97+ esac
91 ;;98 ;;
92 *'"type":"shutdown"'*)99 *'"type":"shutdown"'*)
93 printf '%s\n' '{"type":"shutdown_ack"}'100 printf '%s\n' '{"type":"shutdown_ack"}'
@@ -168,19 +175,22 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
168 cmds := mgr.Commands()175 cmds := mgr.Commands()
169 found := false176 found := false
170 for _, c := range cmds {177 for _, c := range cmds {
171- if c.Name == "ping" {178+ if c.Name == "Ping" {
172 found = true179 found = true
173 break180 break
174 }181 }
175 }182 }
176 if !found {183 if !found {
177- t.Fatalf("expected command 'ping', got %#v", cmds)184+ t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
178 }185 }
179- if !mgr.HasCommand("ping") {186+ if !mgr.HasCommand("PING") {
180- t.Fatal("HasCommand(\"ping\") = false")187+ t.Fatal("HasCommand(\"PING\") = false")
188+ }
189+ if owner := mgr.CommandOwner("pInG"); owner != "mock" {
190+ t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
181 }191 }
182 192
183- resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)193+ resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
184 if err != nil {194 if err != nil {
185 t.Fatalf("invoke: %v", err)195 t.Fatalf("invoke: %v", err)
186 }196 }
@@ -215,7 +225,8 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
215 }225 }
216 226
217 writeDiagExtension("a-first", `#!/bin/sh227 writeDiagExtension("a-first", `#!/bin/sh
218-printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}'228+printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
229+printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
219 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'230 printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
220 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'231 printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
221 printf '%s\n' '{"type":"ready"}'232 printf '%s\n' '{"type":"ready"}'
@@ -226,7 +237,8 @@ while IFS= read -r line; do
226 done237 done
227 `)238 `)
228 writeDiagExtension("b-second", `#!/bin/sh239 writeDiagExtension("b-second", `#!/bin/sh
229-printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}'240+printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
241+printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
230 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'242 printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
231 printf '%s\n' '{"type":"ready"}'243 printf '%s\n' '{"type":"ready"}'
232 while IFS= read -r line; do244 while IFS= read -r line; do
@@ -255,6 +267,7 @@ done
255 }267 }
256 268
257 var shadowedTool bool269 var shadowedTool bool
270+ var activeCommands, shadowedCommands int
258 var conflictMessage bool271 var conflictMessage bool
259 for _, d := range diags {272 for _, d := range diags {
260 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {273 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
@@ -265,10 +278,23 @@ done
265 shadowedTool = true278 shadowedTool = true
266 }279 }
267 }280 }
281+ for _, command := range d.Commands {
282+ if !strings.EqualFold(command.Name, "casetest") {
283+ continue
284+ }
285+ if command.Active {
286+ activeCommands++
287+ } else {
288+ shadowedCommands++
289+ }
290+ }
268 }291 }
269 if !shadowedTool {292 if !shadowedTool {
270 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)293 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
271 }294 }
295+ if activeCommands != 1 || shadowedCommands != 1 {
296+ t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
297+ }
272 if !conflictMessage {298 if !conflictMessage {
273 t.Fatalf("expected conflict diagnostic, got %#v", diags)299 t.Fatalf("expected conflict diagnostic, got %#v", diags)
274 }300 }
M packages/agent/modes/bot/commands.go +22 -0
@@ -2,6 +2,28 @@ package bot
22
33 import "strings"
44
5+// ParseCommand recognizes the built-in slash commands shared by messaging
6+// adapters. Command names are case-insensitive, and trailing arguments do not
7+// affect recognition.
8+func ParseCommand(text string) (Command, bool) {
9+ fields := strings.Fields(text)
10+ if len(fields) == 0 {
11+ return 0, false
12+ }
13+ switch strings.ToLower(fields[0]) {
14+ case "/start":
15+ return CmdStart, true
16+ case "/help":
17+ return CmdHelp, true
18+ case "/status":
19+ return CmdStatus, true
20+ case "/stop":
21+ return CmdStop, true
22+ default:
23+ return 0, false
24+ }
25+}
26+
527 // IsStopCommand reports whether text should abort the active turn.
628 // Users often type plain "stop" rather than bot-style "/stop"; keep
729 // this intentionally narrow so normal prompts like "stop doing X"
@@ -2,6 +2,28 @@ package bot
2 2
3 import "strings"3 import "strings"
4 4
5+// ParseCommand recognizes the built-in slash commands shared by messaging
6+// adapters. Command names are case-insensitive, and trailing arguments do not
7+// affect recognition.
8+func ParseCommand(text string) (Command, bool) {
9+ fields := strings.Fields(text)
10+ if len(fields) == 0 {
11+ return 0, false
12+ }
13+ switch strings.ToLower(fields[0]) {
14+ case "/start":
15+ return CmdStart, true
16+ case "/help":
17+ return CmdHelp, true
18+ case "/status":
19+ return CmdStatus, true
20+ case "/stop":
21+ return CmdStop, true
22+ default:
23+ return 0, false
24+ }
25+}
26+
5 // IsStopCommand reports whether text should abort the active turn.27 // IsStopCommand reports whether text should abort the active turn.
6 // Users often type plain "stop" rather than bot-style "/stop"; keep28 // Users often type plain "stop" rather than bot-style "/stop"; keep
7 // this intentionally narrow so normal prompts like "stop doing X"29 // this intentionally narrow so normal prompts like "stop doing X"
A packages/agent/modes/bot/commands_test.go +38 -0
@@ -0,0 +1,38 @@
1+package bot
2+
3+import "testing"
4+
5+func TestParseCommandCaseInsensitive(t *testing.T) {
6+ tests := []struct {
7+ text string
8+ want Command
9+ ok bool
10+ }{
11+ {text: "/start", want: CmdStart, ok: true},
12+ {text: " /HELP ", want: CmdHelp, ok: true},
13+ {text: "/Status", want: CmdStatus, ok: true},
14+ {text: "/STOP", want: CmdStop, ok: true},
15+ {text: "/stop please", want: CmdStop, ok: true},
16+ {text: "status", ok: false},
17+ {text: "", ok: false},
18+ }
19+ for _, tt := range tests {
20+ got, ok := ParseCommand(tt.text)
21+ if ok != tt.ok || ok && got != tt.want {
22+ t.Errorf("ParseCommand(%q) = (%v, %v), want (%v, %v)", tt.text, got, ok, tt.want, tt.ok)
23+ }
24+ }
25+}
26+
27+func TestIsStopCommand(t *testing.T) {
28+ for _, text := range []string{"stop", " STOP ", "Stop"} {
29+ if !IsStopCommand(text) {
30+ t.Errorf("IsStopCommand(%q) = false", text)
31+ }
32+ }
33+ for _, text := range []string{"/stop", "stop please", "please stop", ""} {
34+ if IsStopCommand(text) {
35+ t.Errorf("IsStopCommand(%q) = true", text)
36+ }
37+ }
38+}
@@ -0,0 +1,38 @@
1+package bot
2+
3+import "testing"
4+
5+func TestParseCommandCaseInsensitive(t *testing.T) {
6+ tests := []struct {
7+ text string
8+ want Command
9+ ok bool
10+ }{
11+ {text: "/start", want: CmdStart, ok: true},
12+ {text: " /HELP ", want: CmdHelp, ok: true},
13+ {text: "/Status", want: CmdStatus, ok: true},
14+ {text: "/STOP", want: CmdStop, ok: true},
15+ {text: "/stop please", want: CmdStop, ok: true},
16+ {text: "status", ok: false},
17+ {text: "", ok: false},
18+ }
19+ for _, tt := range tests {
20+ got, ok := ParseCommand(tt.text)
21+ if ok != tt.ok || ok && got != tt.want {
22+ t.Errorf("ParseCommand(%q) = (%v, %v), want (%v, %v)", tt.text, got, ok, tt.want, tt.ok)
23+ }
24+ }
25+}
26+
27+func TestIsStopCommand(t *testing.T) {
28+ for _, text := range []string{"stop", " STOP ", "Stop"} {
29+ if !IsStopCommand(text) {
30+ t.Errorf("IsStopCommand(%q) = false", text)
31+ }
32+ }
33+ for _, text := range []string{"/stop", "stop please", "please stop", ""} {
34+ if IsStopCommand(text) {
35+ t.Errorf("IsStopCommand(%q) = true", text)
36+ }
37+ }
38+}
M packages/agent/modes/interactive.go +1 -1
@@ -4059,7 +4059,7 @@ func longestCommonPrefix(ss []string) string {
40594059
40604060 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
40614061 parts := strings.Fields(cmd)
4062- switch parts[0] {
4062+ switch strings.ToLower(parts[0]) {
40634063 case "/exit":
40644064 return true
40654065 case "/clear":
@@ -4059,7 +4059,7 @@ func longestCommonPrefix(ss []string) string {
4059 4059
4060 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {4060 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4061 parts := strings.Fields(cmd)4061 parts := strings.Fields(cmd)
4062- switch parts[0] {4062+ switch strings.ToLower(parts[0]) {
4063 case "/exit":4063 case "/exit":
4064 return true4064 return true
4065 case "/clear":4065 case "/clear":
A packages/agent/modes/slash_case_test.go +28 -0
@@ -0,0 +1,28 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+)
8+
9+func TestRunSlashCommandNameCaseInsensitive(t *testing.T) {
10+ i := &Interactive{}
11+ if !i.runSlash(context.Background(), "/Exit") {
12+ t.Fatal("/Exit did not dispatch to /exit")
13+ }
14+}
15+
16+func TestRunSlashPreservesArgumentCase(t *testing.T) {
17+ var got string
18+ i := &Interactive{cfg: InteractiveConfig{
19+ ChangeCWD: func(path string) error {
20+ got = path
21+ return errors.New("stop after capture")
22+ },
23+ }}
24+ i.runSlash(context.Background(), "/CD /Users/Pat/Mixed Case")
25+ if got != "/Users/Pat/Mixed Case" {
26+ t.Fatalf("ChangeCWD path = %q, want original argument case", got)
27+ }
28+}
@@ -0,0 +1,28 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+)
8+
9+func TestRunSlashCommandNameCaseInsensitive(t *testing.T) {
10+ i := &Interactive{}
11+ if !i.runSlash(context.Background(), "/Exit") {
12+ t.Fatal("/Exit did not dispatch to /exit")
13+ }
14+}
15+
16+func TestRunSlashPreservesArgumentCase(t *testing.T) {
17+ var got string
18+ i := &Interactive{cfg: InteractiveConfig{
19+ ChangeCWD: func(path string) error {
20+ got = path
21+ return errors.New("stop after capture")
22+ },
23+ }}
24+ i.runSlash(context.Background(), "/CD /Users/Pat/Mixed Case")
25+ if got != "/Users/Pat/Mixed Case" {
26+ t.Fatalf("ChangeCWD path = %q, want original argument case", got)
27+ }
28+}
M packages/agent/modes/slash_suggest.go +13 -6
@@ -24,7 +24,7 @@ type slashCommand struct {
2424 // or rebuild the agent) need a quiet state; the rest run alongside
2525 // the streaming response without trouble.
2626 func slashCancelsTurn(head string) bool {
27- switch head {
27+ switch strings.ToLower(head) {
2828 case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
2929 return true
3030 }
@@ -86,7 +86,13 @@ type slashSuggester struct {
8686 // randomises).
8787 func (s *slashSuggester) SetExtra(cmds []slashCommand) {
8888 sorted := append([]slashCommand(nil), cmds...)
89- sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
89+ sort.Slice(sorted, func(i, j int) bool {
90+ left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name)
91+ if left == right {
92+ return sorted[i].Name < sorted[j].Name
93+ }
94+ return left < right
95+ })
9096 s.extra = sorted
9197 }
9298
@@ -113,7 +119,7 @@ func (s *slashSuggester) allCatalog() []slashCommand {
113119 for _, c := range s.extra {
114120 dup := false
115121 for _, b := range base {
116- if b.Name == c.Name {
122+ if strings.EqualFold(b.Name, c.Name) {
117123 dup = true
118124 break
119125 }
@@ -189,12 +195,12 @@ func isKnownSlashCommand(text string) bool {
189195 head = text[:i]
190196 }
191197 for _, c := range slashCatalog {
192- if c.Name == head {
198+ if strings.EqualFold(c.Name, head) {
193199 return true
194200 }
195201 }
196202 for _, h := range hiddenSlashCommands {
197- if h == head {
203+ if strings.EqualFold(h, head) {
198204 return true
199205 }
200206 }
@@ -222,6 +228,7 @@ func (s *slashSuggester) matches(input string) []slashCommand {
222228 if idx := strings.IndexByte(input, ' '); idx >= 0 {
223229 return nil
224230 }
231+ matchPrefix := strings.ToLower(input)
225232 var out []slashCommand
226233 for _, c := range s.allCatalog() {
227234 if c.Header {
@@ -231,7 +238,7 @@ func (s *slashSuggester) matches(input string) []slashCommand {
231238 out = append(out, c)
232239 continue
233240 }
234- if strings.HasPrefix(c.Name, input) {
241+ if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) {
235242 out = append(out, c)
236243 }
237244 }
@@ -24,7 +24,7 @@ type slashCommand struct {
24 // or rebuild the agent) need a quiet state; the rest run alongside24 // or rebuild the agent) need a quiet state; the rest run alongside
25 // the streaming response without trouble.25 // the streaming response without trouble.
26 func slashCancelsTurn(head string) bool {26 func slashCancelsTurn(head string) bool {
27- switch head {27+ switch strings.ToLower(head) {
28 case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":28 case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
29 return true29 return true
30 }30 }
@@ -86,7 +86,13 @@ type slashSuggester struct {
86 // randomises).86 // randomises).
87 func (s *slashSuggester) SetExtra(cmds []slashCommand) {87 func (s *slashSuggester) SetExtra(cmds []slashCommand) {
88 sorted := append([]slashCommand(nil), cmds...)88 sorted := append([]slashCommand(nil), cmds...)
89- sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })89+ sort.Slice(sorted, func(i, j int) bool {
90+ left, right := strings.ToLower(sorted[i].Name), strings.ToLower(sorted[j].Name)
91+ if left == right {
92+ return sorted[i].Name < sorted[j].Name
93+ }
94+ return left < right
95+ })
90 s.extra = sorted96 s.extra = sorted
91 }97 }
92 98
@@ -113,7 +119,7 @@ func (s *slashSuggester) allCatalog() []slashCommand {
113 for _, c := range s.extra {119 for _, c := range s.extra {
114 dup := false120 dup := false
115 for _, b := range base {121 for _, b := range base {
116- if b.Name == c.Name {122+ if strings.EqualFold(b.Name, c.Name) {
117 dup = true123 dup = true
118 break124 break
119 }125 }
@@ -189,12 +195,12 @@ func isKnownSlashCommand(text string) bool {
189 head = text[:i]195 head = text[:i]
190 }196 }
191 for _, c := range slashCatalog {197 for _, c := range slashCatalog {
192- if c.Name == head {198+ if strings.EqualFold(c.Name, head) {
193 return true199 return true
194 }200 }
195 }201 }
196 for _, h := range hiddenSlashCommands {202 for _, h := range hiddenSlashCommands {
197- if h == head {203+ if strings.EqualFold(h, head) {
198 return true204 return true
199 }205 }
200 }206 }
@@ -222,6 +228,7 @@ func (s *slashSuggester) matches(input string) []slashCommand {
222 if idx := strings.IndexByte(input, ' '); idx >= 0 {228 if idx := strings.IndexByte(input, ' '); idx >= 0 {
223 return nil229 return nil
224 }230 }
231+ matchPrefix := strings.ToLower(input)
225 var out []slashCommand232 var out []slashCommand
226 for _, c := range s.allCatalog() {233 for _, c := range s.allCatalog() {
227 if c.Header {234 if c.Header {
@@ -231,7 +238,7 @@ func (s *slashSuggester) matches(input string) []slashCommand {
231 out = append(out, c)238 out = append(out, c)
232 continue239 continue
233 }240 }
234- if strings.HasPrefix(c.Name, input) {241+ if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) {
235 out = append(out, c)242 out = append(out, c)
236 }243 }
237 }244 }
M packages/agent/modes/slash_suggest_test.go +22 -0
@@ -39,6 +39,28 @@ func TestSlashSuggesterHasSwarm(t *testing.T) {
3939 }
4040 }
4141
42+func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
43+ s := newSlashSuggester()
44+ if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
45+ t.Fatalf("/EX did not suggest /exit: %v", got)
46+ }
47+ if !isKnownSlashCommand("/Exit") {
48+ t.Fatal("/Exit was not recognized as a built-in command")
49+ }
50+ if !slashCancelsTurn("/CLEAR") {
51+ t.Fatal("/CLEAR did not retain /clear cancellation semantics")
52+ }
53+}
54+
55+func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
56+ s := newSlashSuggester()
57+ s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}})
58+ matches := commandNames(s.matches("/exit"))
59+ if len(matches) != 1 || matches[0] != "/exit" {
60+ t.Fatalf("matches = %v, want only built-in /exit", matches)
61+ }
62+}
63+
4264 func commandNames(cmds []slashCommand) []string {
4365 out := make([]string, 0, len(cmds))
4466 for _, c := range cmds {
@@ -39,6 +39,28 @@ func TestSlashSuggesterHasSwarm(t *testing.T) {
39 }39 }
40 }40 }
41 41
42+func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
43+ s := newSlashSuggester()
44+ if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
45+ t.Fatalf("/EX did not suggest /exit: %v", got)
46+ }
47+ if !isKnownSlashCommand("/Exit") {
48+ t.Fatal("/Exit was not recognized as a built-in command")
49+ }
50+ if !slashCancelsTurn("/CLEAR") {
51+ t.Fatal("/CLEAR did not retain /clear cancellation semantics")
52+ }
53+}
54+
55+func TestSlashSuggesterBuiltinsShadowExtensionsCaseInsensitively(t *testing.T) {
56+ s := newSlashSuggester()
57+ s.SetExtra([]slashCommand{{Name: "/EXIT", Desc: "extension exit"}})
58+ matches := commandNames(s.matches("/exit"))
59+ if len(matches) != 1 || matches[0] != "/exit" {
60+ t.Fatalf("matches = %v, want only built-in /exit", matches)
61+ }
62+}
63+
42 func commandNames(cmds []slashCommand) []string {64 func commandNames(cmds []slashCommand) []string {
43 out := make([]string, 0, len(cmds))65 out := make([]string, 0, len(cmds))
44 for _, c := range cmds {66 for _, c := range cmds {
M packages/agent/modes/swarm_dialog.go +7 -4
@@ -699,8 +699,7 @@ func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg
699699 // vs. as a task description.
700700 func containsSlashModelLine(buf string) bool {
701701 for _, ln := range strings.Split(buf, "\n") {
702- t := strings.TrimSpace(ln)
703- if t == "/model" || strings.HasPrefix(t, "/model ") {
702+ if isSlashModelLine(ln) {
704703 return true
705704 }
706705 }
@@ -713,12 +712,16 @@ func containsSlashModelLine(buf string) bool {
713712 // is always exactly one full line in practice — the user typed
714713 // /model<Enter> — but we tolerate inline placement at the start of
715714 // a line just in case.
715+func isSlashModelLine(line string) bool {
716+ fields := strings.Fields(strings.TrimSpace(line))
717+ return len(fields) > 0 && strings.EqualFold(fields[0], "/model")
718+}
719+
716720 func stripSlashModelLine(buf string) string {
717721 lines := strings.Split(buf, "\n")
718722 out := lines[:0]
719723 for _, ln := range lines {
720- t := strings.TrimSpace(ln)
721- if t == "/model" || strings.HasPrefix(t, "/model ") {
724+ if isSlashModelLine(ln) {
722725 continue
723726 }
724727 out = append(out, ln)
@@ -699,8 +699,7 @@ func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg
699 // vs. as a task description.699 // vs. as a task description.
700 func containsSlashModelLine(buf string) bool {700 func containsSlashModelLine(buf string) bool {
701 for _, ln := range strings.Split(buf, "\n") {701 for _, ln := range strings.Split(buf, "\n") {
702- t := strings.TrimSpace(ln)702+ if isSlashModelLine(ln) {
703- if t == "/model" || strings.HasPrefix(t, "/model ") {
704 return true703 return true
705 }704 }
706 }705 }
@@ -713,12 +712,16 @@ func containsSlashModelLine(buf string) bool {
713 // is always exactly one full line in practice — the user typed712 // is always exactly one full line in practice — the user typed
714 // /model<Enter> — but we tolerate inline placement at the start of713 // /model<Enter> — but we tolerate inline placement at the start of
715 // a line just in case.714 // a line just in case.
715+func isSlashModelLine(line string) bool {
716+ fields := strings.Fields(strings.TrimSpace(line))
717+ return len(fields) > 0 && strings.EqualFold(fields[0], "/model")
718+}
719+
716 func stripSlashModelLine(buf string) string {720 func stripSlashModelLine(buf string) string {
717 lines := strings.Split(buf, "\n")721 lines := strings.Split(buf, "\n")
718 out := lines[:0]722 out := lines[:0]
719 for _, ln := range lines {723 for _, ln := range lines {
720- t := strings.TrimSpace(ln)724+ if isSlashModelLine(ln) {
721- if t == "/model" || strings.HasPrefix(t, "/model ") {
722 continue725 continue
723 }726 }
724 out = append(out, ln)727 out = append(out, ln)
M packages/agent/modes/swarm_dialog_test.go +1 -0
@@ -1029,6 +1029,7 @@ func TestStripSlashModelLine(t *testing.T) {
10291029 {"/model\nfoo", "foo"},
10301030 {"foo\n/model\nbar", "foo\nbar"},
10311031 {"/model gpt-5", ""},
1032+ {"/MODEL GPT-5", ""},
10321033 {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
10331034 {"", ""},
10341035 {"plain task", "plain task"},
@@ -1029,6 +1029,7 @@ func TestStripSlashModelLine(t *testing.T) {
1029 {"/model\nfoo", "foo"},1029 {"/model\nfoo", "foo"},
1030 {"foo\n/model\nbar", "foo\nbar"},1030 {"foo\n/model\nbar", "foo\nbar"},
1031 {"/model gpt-5", ""},1031 {"/model gpt-5", ""},
1032+ {"/MODEL GPT-5", ""},
1032 {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep1033 {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1033 {"", ""},1034 {"", ""},
1034 {"plain task", "plain task"},1035 {"plain task", "plain task"},
M packages/agent/modes/telegram/adapter.go +4 -13
@@ -115,8 +115,9 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
115115
116116 // Pairing: first user who sends /start claims the bridge.
117117 text := strings.TrimSpace(msg.Text)
118+ command, isCommand := bot.ParseCommand(text)
118119 if a.Cfg.AllowedUserID == 0 {
119- if strings.HasPrefix(text, "/start") {
120+ if isCommand && command == bot.CmdStart {
120121 a.Cfg.AllowedUserID = msg.From.ID
121122 _ = a.Save(*a.Cfg)
122123 _ = a.Client.SendMessage(ctx, msg.Chat.ID,
@@ -144,18 +145,8 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
144145 }
145146
146147 // Built-in commands that bypass the agent.
147- switch text {
148- case "/start":
149- commandHandler(bot.CmdStart, inbound)
150- return
151- case "/help":
152- commandHandler(bot.CmdHelp, inbound)
153- return
154- case "/status":
155- commandHandler(bot.CmdStatus, inbound)
156- return
157- case "/stop":
158- commandHandler(bot.CmdStop, inbound)
148+ if isCommand {
149+ commandHandler(command, inbound)
159150 return
160151 }
161152 if bot.IsStopCommand(text) {
@@ -115,8 +115,9 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
115 115
116 // Pairing: first user who sends /start claims the bridge.116 // Pairing: first user who sends /start claims the bridge.
117 text := strings.TrimSpace(msg.Text)117 text := strings.TrimSpace(msg.Text)
118+ command, isCommand := bot.ParseCommand(text)
118 if a.Cfg.AllowedUserID == 0 {119 if a.Cfg.AllowedUserID == 0 {
119- if strings.HasPrefix(text, "/start") {120+ if isCommand && command == bot.CmdStart {
120 a.Cfg.AllowedUserID = msg.From.ID121 a.Cfg.AllowedUserID = msg.From.ID
121 _ = a.Save(*a.Cfg)122 _ = a.Save(*a.Cfg)
122 _ = a.Client.SendMessage(ctx, msg.Chat.ID,123 _ = a.Client.SendMessage(ctx, msg.Chat.ID,
@@ -144,18 +145,8 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
144 }145 }
145 146
146 // Built-in commands that bypass the agent.147 // Built-in commands that bypass the agent.
147- switch text {148+ if isCommand {
148- case "/start":149+ commandHandler(command, inbound)
149- commandHandler(bot.CmdStart, inbound)
150- return
151- case "/help":
152- commandHandler(bot.CmdHelp, inbound)
153- return
154- case "/status":
155- commandHandler(bot.CmdStatus, inbound)
156- return
157- case "/stop":
158- commandHandler(bot.CmdStop, inbound)
159 return150 return
160 }151 }
161 if bot.IsStopCommand(text) {152 if bot.IsStopCommand(text) {
M packages/agent/modes/telegram/api_test.go +21 -0
@@ -75,6 +75,27 @@ func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) {
7575 }
7676 }
7777
78+func TestAdapterCommandsAreCaseInsensitive(t *testing.T) {
79+ cfg := Config{AllowedUserID: 42}
80+ adapter := NewAdapter(nil, &cfg, func(Config) error { return nil })
81+ var got bot.Command
82+ var called bool
83+ adapter.handleUpdate(context.Background(), Update{Message: &Message{
84+ MessageID: 1,
85+ From: &User{ID: 42},
86+ Chat: Chat{ID: 7, Type: "private"},
87+ Text: "/STOP",
88+ }}, func(bot.InboundMessage) {
89+ t.Fatal("uppercase slash command was forwarded as a prompt")
90+ }, func(command bot.Command, _ bot.InboundMessage) {
91+ got = command
92+ called = true
93+ })
94+ if !called || got != bot.CmdStop {
95+ t.Fatalf("command = %v, called = %v, want CmdStop", got, called)
96+ }
97+}
98+
7899 func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) {
79100 ctx, cancel := context.WithCancel(context.Background())
80101 defer cancel()
@@ -75,6 +75,27 @@ func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) {
75 }75 }
76 }76 }
77 77
78+func TestAdapterCommandsAreCaseInsensitive(t *testing.T) {
79+ cfg := Config{AllowedUserID: 42}
80+ adapter := NewAdapter(nil, &cfg, func(Config) error { return nil })
81+ var got bot.Command
82+ var called bool
83+ adapter.handleUpdate(context.Background(), Update{Message: &Message{
84+ MessageID: 1,
85+ From: &User{ID: 42},
86+ Chat: Chat{ID: 7, Type: "private"},
87+ Text: "/STOP",
88+ }}, func(bot.InboundMessage) {
89+ t.Fatal("uppercase slash command was forwarded as a prompt")
90+ }, func(command bot.Command, _ bot.InboundMessage) {
91+ got = command
92+ called = true
93+ })
94+ if !called || got != bot.CmdStop {
95+ t.Fatalf("command = %v, called = %v, want CmdStop", got, called)
96+ }
97+}
98+
78 func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) {99 func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) {
79 ctx, cancel := context.WithCancel(context.Background())100 ctx, cancel := context.WithCancel(context.Background())
80 defer cancel()101 defer cancel()
M packages/agent/modes/telegram/bridge.go +16 -15
@@ -349,13 +349,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
349349 }
350350
351351 text := strings.TrimSpace(msg.Text)
352+ command, isCommand := bot.ParseCommand(text)
352353
353354 // Pairing: first user who sends /start claims the bridge.
354355 b.mu.Lock()
355356 paired := b.Config.AllowedUserID
356357 b.mu.Unlock()
357358 if paired == 0 {
358- if strings.HasPrefix(text, "/start") {
359+ if isCommand && command == bot.CmdStart {
359360 b.mu.Lock()
360361 b.Config.AllowedUserID = msg.From.ID
361362 b.chatID = msg.Chat.ID
@@ -387,20 +388,20 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
387388 b.mu.Unlock()
388389
389390 // Built-in commands that bypass the agent.
390- switch text {
391- case "/start", "/help":
392- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
393- "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
394- msg.MessageID)
395- return
396- case "/status":
397- _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
398- return
399- case "/stop":
400- b.Host.CancelTurn()
401- b.stopWorking()
402- _ = b.Client.SendMessage(ctx, msg.Chat.ID,
403- "cancelled the current turn.", msg.MessageID)
391+ if isCommand {
392+ switch command {
393+ case bot.CmdStart, bot.CmdHelp:
394+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
395+ "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
396+ msg.MessageID)
397+ case bot.CmdStatus:
398+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
399+ case bot.CmdStop:
400+ b.Host.CancelTurn()
401+ b.stopWorking()
402+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
403+ "cancelled the current turn.", msg.MessageID)
404+ }
404405 return
405406 }
406407 if isStopCommand(text) {
@@ -349,13 +349,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
349 }349 }
350 350
351 text := strings.TrimSpace(msg.Text)351 text := strings.TrimSpace(msg.Text)
352+ command, isCommand := bot.ParseCommand(text)
352 353
353 // Pairing: first user who sends /start claims the bridge.354 // Pairing: first user who sends /start claims the bridge.
354 b.mu.Lock()355 b.mu.Lock()
355 paired := b.Config.AllowedUserID356 paired := b.Config.AllowedUserID
356 b.mu.Unlock()357 b.mu.Unlock()
357 if paired == 0 {358 if paired == 0 {
358- if strings.HasPrefix(text, "/start") {359+ if isCommand && command == bot.CmdStart {
359 b.mu.Lock()360 b.mu.Lock()
360 b.Config.AllowedUserID = msg.From.ID361 b.Config.AllowedUserID = msg.From.ID
361 b.chatID = msg.Chat.ID362 b.chatID = msg.Chat.ID
@@ -387,20 +388,20 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
387 b.mu.Unlock()388 b.mu.Unlock()
388 389
389 // Built-in commands that bypass the agent.390 // Built-in commands that bypass the agent.
390- switch text {391+ if isCommand {
391- case "/start", "/help":392+ switch command {
392- _ = b.Client.SendMessage(ctx, msg.Chat.ID,393+ case bot.CmdStart, bot.CmdHelp:
393- "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",394+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
394- msg.MessageID)395+ "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
395- return396+ msg.MessageID)
396- case "/status":397+ case bot.CmdStatus:
397- _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)398+ _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
398- return399+ case bot.CmdStop:
399- case "/stop":400+ b.Host.CancelTurn()
400- b.Host.CancelTurn()401+ b.stopWorking()
401- b.stopWorking()402+ _ = b.Client.SendMessage(ctx, msg.Chat.ID,
402- _ = b.Client.SendMessage(ctx, msg.Chat.ID,403+ "cancelled the current turn.", msg.MessageID)
403- "cancelled the current turn.", msg.MessageID)404+ }
404 return405 return
405 }406 }
406 if isStopCommand(text) {407 if isStopCommand(text) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close