Skip to content

Commit fc93814

Browse files
cnjackt
andcommitted
feat(flow): workflow /<name> slash commands across TUI, Web, and ACP (#120)
* feat(flow): workflow /<name> slash commands across TUI, Web, and ACP Every saved workflow now surfaces as a `/<name>` slash command on all three frontends, badged as a workflow so it's distinct from skills. Typing the slash runs the saved workflow by name via the workflow_run tool; any text after the slash is handed to the agent as the workflow's `args`. - internal/flow/slash.go: shared SlashRunPrompt + GetBySlash so every frontend expands a workflow slash identically - internal/web: /api/slash-commands advertises workflow slashes (type "flow") alongside skills; the web autocomplete surfaces and badges them - TUI + ACP: workflow slashes join the command menu / availableCommandList - docs: workflows.md documents the slash entry point - .gitignore: ignore the local .jcode workflow store Generated with Jack AI bot * fix(flow): address PR #120 review — task-scope workflow slashes, fix badge token, doc fence - Web slash advertising (/api/slash-commands) and slash rewrites (submitMessage) now resolve against the foreground task's project loader (Engine.flowLoader), falling back to the boot loader, so a task running in a different project sees its own .jcode/workflows — matching the per-task loader workflow_run already uses. Per-task loader is carried on the Engine via EngineConfig.FlowLoader. - Fix the workflow badge referencing an undefined token: --color-accent-wash does not exist; tokens.css defines --accent-wash. - Tag the docs example fence as `text` so markdownlint (MD040) stays clean. Generated with Jack AI bot --------- Co-authored-by: t <t@example.com>
1 parent ae20d77 commit fc93814

16 files changed

Lines changed: 412 additions & 15 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ docs/.jekyll-metadata
1414
.claude
1515
site/dist
1616
site/node_modules
17+
.jcode

internal/command/acp.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
"github.com/cnjack/jcode/internal/agent"
2222
"github.com/cnjack/jcode/internal/config"
23+
"github.com/cnjack/jcode/internal/flow"
2324
"github.com/cnjack/jcode/internal/handler"
2425
"github.com/cnjack/jcode/internal/hooks"
2526
mempipeline "github.com/cnjack/jcode/internal/memory/pipeline"
@@ -80,6 +81,7 @@ type acpSession struct {
8081
normalPrompt string
8182
planPrompt string
8283
skillLoader *skills.Loader
84+
flowLoader *flow.Loader
8385
}
8486

8587
// Close releases resources held by the session (recorder file handle, tracer).
@@ -145,7 +147,7 @@ func handleACPSubcommand() {
145147

146148
// availableCommandList builds the slash commands advertised to ACP clients:
147149
// the built-in /goal command plus any skill-based commands.
148-
func availableCommandList(skillLoader *skills.Loader) []acp.AvailableCommand {
150+
func availableCommandList(skillLoader *skills.Loader, flowLoader *flow.Loader) []acp.AvailableCommand {
149151
cmds := []acp.AvailableCommand{
150152
{
151153
Name: "goal",
@@ -176,13 +178,32 @@ func availableCommandList(skillLoader *skills.Loader) []acp.AvailableCommand {
176178
})
177179
}
178180
}
181+
if flowLoader != nil {
182+
for _, fc := range flowLoader.SlashCommands() {
183+
name := strings.TrimPrefix(fc.Slash, "/")
184+
if name == "goal" {
185+
continue
186+
}
187+
// ACP has no "type" field, so mark workflows in the description so
188+
// editor command palettes distinguish them from skills.
189+
cmds = append(cmds, acp.AvailableCommand{
190+
Name: name,
191+
Description: "workflow — " + fc.Description,
192+
Input: &acp.AvailableCommandInput{
193+
Unstructured: &acp.UnstructuredCommandInput{
194+
Hint: "args / context",
195+
},
196+
},
197+
})
198+
}
199+
}
179200
return cmds
180201
}
181202

182203
// broadcastSlashCommands sends the available slash commands to the client via
183204
// an available_commands_update session notification.
184205
func (a *acpAgent) broadcastSlashCommands(sessionID acp.SessionId, sess *acpSession) {
185-
cmds := availableCommandList(sess.skillLoader)
206+
cmds := availableCommandList(sess.skillLoader, sess.flowLoader)
186207
if len(cmds) == 0 {
187208
return
188209
}
@@ -324,6 +345,9 @@ func (a *acpAgent) buildAgentSession(
324345
skillLoader := skills.NewLoader()
325346
skillLoader.ScanProjectSkills(pwd)
326347

348+
flowLoader := flow.NewLoader()
349+
flowLoader.LoadProject(pwd)
350+
327351
providerName, modelName := cfg.GetProviderModel()
328352
providers := cfg.GetProviders()
329353
providerCfg := providers[providerName]
@@ -371,6 +395,7 @@ func (a *acpAgent) buildAgentSession(
371395
env.NewWorkflowRunTool(&tools.WorkflowToolDeps{
372396
ModelFactory: internalmodel.NewModelFactory(cfg, chatModel),
373397
Recorder: rec,
398+
Loader: flowLoader,
374399
}),
375400
}
376401
if config.MemoryEnabled(cfg) {
@@ -522,6 +547,7 @@ func (a *acpAgent) buildAgentSession(
522547
normalPrompt: normalPrompt,
523548
planPrompt: planPrompt,
524549
skillLoader: skillLoader,
550+
flowLoader: flowLoader,
525551
}
526552

527553
// Reconcile the session's advertised mode when the handler promotes to
@@ -623,13 +649,26 @@ func (a *acpAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Pr
623649
}
624650

625651
// Check if it's a skill slash command.
652+
matchedSkill := false
626653
if sess.skillLoader != nil {
627654
if sk := sess.skillLoader.GetBySlash("/" + cmdName); sk != nil {
628655
userInput := ""
629656
if len(parts) > 1 {
630657
userInput = parts[1]
631658
}
632659
prompt = fmt.Sprintf("Use the load_skill tool with name=%q and follow its instructions. %s", sk.Name, userInput)
660+
matchedSkill = true
661+
}
662+
}
663+
664+
// Otherwise check workflow slash commands (e.g. /repo-audit).
665+
if !matchedSkill && sess.flowLoader != nil {
666+
if wf, ok := sess.flowLoader.GetBySlash("/" + cmdName); ok {
667+
userInput := ""
668+
if len(parts) > 1 {
669+
userInput = parts[1]
670+
}
671+
prompt = flow.SlashRunPrompt(wf.Meta.Name, userInput)
633672
}
634673
}
635674
}

internal/command/acp_goal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package command
33
import "testing"
44

55
func TestAvailableCommandList_IncludesGoal(t *testing.T) {
6-
cmds := availableCommandList(nil)
6+
cmds := availableCommandList(nil, nil)
77
if len(cmds) == 0 {
88
t.Fatal("expected at least the goal command")
99
}

internal/command/interactive.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/cnjack/jcode/internal/browser"
2424
"github.com/cnjack/jcode/internal/channel"
2525
"github.com/cnjack/jcode/internal/config"
26+
"github.com/cnjack/jcode/internal/flow"
2627
"github.com/cnjack/jcode/internal/handler"
2728
"github.com/cnjack/jcode/internal/hooks"
2829
mempipeline "github.com/cnjack/jcode/internal/memory/pipeline"
@@ -62,6 +63,7 @@ type interactiveState struct {
6263
platform string
6364
registry *internalmodel.ModelRegistry
6465
skillLoader *skills.Loader
66+
flowLoader *flow.Loader
6567
langfuseTracer *telemetry.LangfuseTracer
6668
h handler.AgentEventHandler
6769
askUserDeps *tools.AskUserDeps
@@ -108,6 +110,7 @@ func (s *interactiveState) buildAllTools() []tool.BaseTool {
108110
ModelFactory: internalmodel.NewModelFactory(s.cfg, s.chatModel),
109111
Recorder: s.rec,
110112
Tracer: s.langfuseTracer,
113+
Loader: s.flowLoader,
111114
}),
112115
tools.NewAskUserTool(s.askUserDeps),
113116
skills.NewLoadSkillTool(s.skillLoader),
@@ -812,6 +815,19 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes
812815
s.p.Send(tui.SkillsLoadedMsg{SlashCommands: slashInfos})
813816
}
814817

818+
if s.flowLoader != nil {
819+
if slashFlows := s.flowLoader.SlashCommands(); len(slashFlows) > 0 {
820+
var flowInfos []tui.FlowSlashInfo
821+
for _, fc := range slashFlows {
822+
flowInfos = append(flowInfos, tui.FlowSlashInfo{
823+
Slash: fc.Slash,
824+
Description: fc.Description,
825+
})
826+
}
827+
s.p.Send(tui.FlowsLoadedMsg{SlashCommands: flowInfos})
828+
}
829+
}
830+
815831
s.history = initialHistory
816832
if initialResumeUUID != "" {
817833
s.p.Send(tui.SessionResumedMsg{UUID: initialResumeUUID, Entries: initialResumeEntries})
@@ -977,6 +993,9 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error {
977993
skillLoader := skills.NewLoaderWithDisabled(cfg.DisabledSkills)
978994
skillLoader.ScanProjectSkills(pwd)
979995

996+
flowLoader := flow.NewLoader()
997+
flowLoader.LoadProject(pwd)
998+
980999
// Memory distillation runs in the background on session start (design
9811000
// §5.1); one-shot -p runs are excluded, gates (cooldown/budget/lock) are
9821001
// inside the pipeline.
@@ -1071,6 +1090,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error {
10711090
platform: platform,
10721091
registry: registry,
10731092
skillLoader: skillLoader,
1093+
flowLoader: flowLoader,
10741094
askUserDeps: askUserDeps,
10751095
mcpTools: mcpTools,
10761096
rec: rec,

internal/command/web.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/cnjack/jcode/internal/channel/ble"
3030
"github.com/cnjack/jcode/internal/config"
3131
"github.com/cnjack/jcode/internal/feature"
32+
"github.com/cnjack/jcode/internal/flow"
3233
"github.com/cnjack/jcode/internal/handler"
3334
mempipeline "github.com/cnjack/jcode/internal/memory/pipeline"
3435
"github.com/cnjack/jcode/internal/mode"
@@ -239,6 +240,9 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err
239240
skillLoader := skills.NewLoaderWithDisabled(cfg.DisabledSkills)
240241
skillLoader.ScanProjectSkills(pwd)
241242

243+
flowLoader := flow.NewLoader()
244+
flowLoader.LoadProject(pwd)
245+
242246
var providerName, modelName string
243247
if !needsSetup {
244248
providerName, modelName = cfg.GetProviderModel()
@@ -406,6 +410,14 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err
406410
taskEnvInfo = util.CollectEnvInfo(taskPwd)
407411
}
408412

413+
// Per-task flow loader (builtin + user + this task's project workflows),
414+
// shared with the workflow_run tool so slash triggers and inline runs
415+
// resolve the same set. Project workflows only apply to a local exec.
416+
taskFlowLoader := flow.NewLoader()
417+
if exec == nil {
418+
taskFlowLoader.LoadProject(taskPwd)
419+
}
420+
409421
tbg := tools.NewBackgroundManager(tenv)
410422
trec, _ := session.NewRecorder(projectKey, providerName, modelName)
411423
if taskID != "" && trec != nil {
@@ -494,6 +506,7 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err
494506
tenv.NewWorkflowRunTool(&tools.WorkflowToolDeps{
495507
ModelFactory: internalmodel.NewModelFactory(cfg, cm),
496508
Recorder: trec,
509+
Loader: taskFlowLoader,
497510
}),
498511
tools.NewAskUserTool(&tools.AskUserDeps{
499512
BatchRequestFn: twh.RequestAskUser,
@@ -710,6 +723,7 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err
710723
BreakdownFn: breakdownFn,
711724
CreateAgent: createAgent,
712725
RebuildForMode: rebuildForMode,
726+
FlowLoader: taskFlowLoader,
713727
}, nil
714728
}
715729

@@ -764,6 +778,7 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err
764778
Registry: registry,
765779
ApprovalState: bootEC.ApprovalState,
766780
SkillLoader: skillLoader,
781+
FlowLoader: flowLoader,
767782
ReloadMCP: reloadMCPTools,
768783
InitialMCPStatuses: initialMCPStatuses,
769784
WechatClient: wechatClient,

internal/flow/slash.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package flow
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// GetBySlash returns the workflow whose auto-generated "/<name>" trigger matches
9+
// slash. Workflows only expose auto-generated slashes (see SlashCommands), so this
10+
// is a name lookup with the leading "/" stripped.
11+
func (l *Loader) GetBySlash(slash string) (Workflow, bool) {
12+
return l.Get(strings.TrimPrefix(slash, "/"))
13+
}
14+
15+
// SlashRunPrompt is the agent instruction a "/<workflow>" slash command expands
16+
// to on every frontend (TUI / Web / ACP). It tells the agent to run the named
17+
// saved workflow via the workflow_run tool rather than authoring an inline
18+
// script; any text the user typed after the slash is handed over so the agent can
19+
// shape it into the workflow's `args` object. Keeping the wording here means all
20+
// frontends stay in lockstep.
21+
func SlashRunPrompt(name, userInput string) string {
22+
var b strings.Builder
23+
fmt.Fprintf(&b, "Run the saved workflow %q by calling the workflow_run tool with name=%q. "+
24+
"Do not write an inline script — run the saved workflow by name.", name, name)
25+
if s := strings.TrimSpace(userInput); s != "" {
26+
fmt.Fprintf(&b, "\n\nShape the workflow's `args` from this input: %s", s)
27+
}
28+
return b.String()
29+
}

internal/tui/input_views.go

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
tea "charm.land/bubbletea/v2"
1212
"charm.land/lipgloss/v2"
1313
"github.com/cnjack/jcode/internal/config"
14+
"github.com/cnjack/jcode/internal/flow"
1415
"github.com/cnjack/jcode/internal/memory"
1516
"github.com/cnjack/jcode/internal/mode"
1617
"github.com/cnjack/jcode/internal/tools"
@@ -42,9 +43,23 @@ func (m Model) getAllCommands() []commandSuggestion {
4243
for _, sc := range m.skillSlashCommands {
4344
commands = append(commands, commandSuggestion{sc.Slash, sc.Description})
4445
}
46+
for _, fc := range m.flowSlashCommands {
47+
commands = append(commands, commandSuggestion{fc.Slash, fc.Description})
48+
}
4549
return commands
4650
}
4751

52+
// isFlowSlash reports whether cmd (e.g. "/repo-audit") is a workflow slash
53+
// command, used to mark it distinctly in the suggestion list.
54+
func (m Model) isFlowSlash(cmd string) bool {
55+
for _, fc := range m.flowSlashCommands {
56+
if fc.Slash == cmd {
57+
return true
58+
}
59+
}
60+
return false
61+
}
62+
4863
// filterCommands returns commands that match the given prefix.
4964
func filterCommands(commands []commandSuggestion, prefix string) []commandSuggestion {
5065
var matches []commandSuggestion
@@ -158,18 +173,27 @@ func (m Model) renderCommandSuggestions() string {
158173
s := suggestions[i]
159174
cmdText := s.cmd
160175
descText := s.desc
176+
isFlow := m.isFlowSlash(s.cmd)
161177
if i == m.cmdSuggestionIndex {
162178
// Highlighted item
163179
cmdStyled := lipgloss.NewStyle().Bold(true).Foreground(colorOnPrimary).Background(colorPrimary).Render(cmdText)
164180
descStyled := lipgloss.NewStyle().Foreground(colorOnPrimary).Background(colorPrimary).Render(" " + descText)
181+
tag := ""
182+
if isFlow {
183+
tag = lipgloss.NewStyle().Italic(true).Foreground(colorOnPrimary).Background(colorPrimary).Render(" workflow")
184+
}
165185
// Indicator
166186
indicator := lipgloss.NewStyle().Foreground(colorPrimary).Render("❯")
167-
lines = append(lines, fmt.Sprintf(" %s %s%s", indicator, cmdStyled, descStyled))
187+
lines = append(lines, fmt.Sprintf(" %s %s%s%s", indicator, cmdStyled, tag, descStyled))
168188
} else {
169189
cmdStyled := lipgloss.NewStyle().Foreground(colorText).Render(cmdText)
170190
descStyled := lipgloss.NewStyle().Foreground(colorMuted).Render(" " + descText)
191+
tag := ""
192+
if isFlow {
193+
tag = lipgloss.NewStyle().Italic(true).Foreground(colorPrimary).Render(" workflow")
194+
}
171195
indicator := lipgloss.NewStyle().Foreground(colorMuted).Render(" ")
172-
lines = append(lines, fmt.Sprintf(" %s %s%s", indicator, cmdStyled, descStyled))
196+
lines = append(lines, fmt.Sprintf(" %s %s%s%s", indicator, cmdStyled, tag, descStyled))
173197
}
174198
}
175199

@@ -442,6 +466,51 @@ func (m *Model) handleSkillSlashInput(skillName, userInput string, cmds []tea.Cm
442466
return m, tea.Batch(cmds...)
443467
}
444468

469+
// matchFlowSlash checks if the prompt matches a registered workflow slash command.
470+
// Returns a FlowSlashMsg if matched, nil otherwise.
471+
func (m Model) matchFlowSlash(prompt string) *FlowSlashMsg {
472+
for _, fc := range m.flowSlashCommands {
473+
if prompt == fc.Slash || strings.HasPrefix(prompt, fc.Slash+" ") {
474+
userInput := ""
475+
if strings.HasPrefix(prompt, fc.Slash+" ") {
476+
userInput = strings.TrimSpace(prompt[len(fc.Slash):])
477+
}
478+
return &FlowSlashMsg{
479+
FlowName: strings.TrimPrefix(fc.Slash, "/"),
480+
UserInput: userInput,
481+
}
482+
}
483+
}
484+
return nil
485+
}
486+
487+
// handleFlowSlashInput handles a workflow slash command by sending a prompt that
488+
// runs the saved workflow via the workflow_run tool.
489+
func (m *Model) handleFlowSlashInput(flowName, userInput string, cmds []tea.Cmd) (tea.Model, tea.Cmd) {
490+
prompt := flow.SlashRunPrompt(flowName, userInput)
491+
492+
displayLabel := "/" + flowName
493+
if userInput != "" {
494+
displayLabel += " " + userInput
495+
}
496+
497+
m.mode = ModeAgent
498+
m.agentDone = false
499+
m.thinking = true
500+
m.lines = append(m.lines, textLine(fmt.Sprintf("%s %s",
501+
userLabelStyle.Render("Workflow:"), displayLabel)))
502+
if m.ready {
503+
m.viewport.SetHeight(m.calcViewportHeight(false))
504+
m.viewport.SetContent(m.renderViewportContent())
505+
m.viewport.GotoBottom()
506+
}
507+
cmds = append(cmds, func() tea.Msg {
508+
return PromptSubmitMsg{Prompt: prompt}
509+
})
510+
cmds = append(cmds, m.spinner.Tick)
511+
return m, tea.Batch(cmds...)
512+
}
513+
445514
// renderModePills renders the unified Ask for approval/Plan/Full access mode selector line
446515
// above the input. The three states map onto distinct pill styles; Shift+Tab
447516
// cycles between them.

0 commit comments

Comments
 (0)