woopcode 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/agent.tsx +57 -52
- package/commands/slash/README.md +114 -0
- package/commands/slash/commands.ts +328 -0
- package/commands/slash/handler.ts +40 -0
- package/commands/slash/index.ts +9 -0
- package/commands/slash/parser.ts +25 -0
- package/commands/slash/registry.ts +66 -0
- package/commands/slash/types.ts +23 -0
- package/package.json +1 -1
- package/tui/src/prompt.tsx +20 -0
- package/tui/src/store/ui-store.ts +16 -0
- package/tui/src/timeline.tsx +12 -0
- package/tui/src/types.ts +5 -0
package/commands/agent.tsx
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import {
|
|
3
|
-
buildRepositoryContext,
|
|
4
|
-
getConfig,
|
|
5
|
-
getConversation,
|
|
6
|
-
saveConversation,
|
|
7
|
-
} from "../config/config";
|
|
8
|
-
import { createProviderClient } from "../config/client";
|
|
9
|
-
import type { Message } from "../config/types";
|
|
2
|
+
import { getConfig } from "../config/config";
|
|
10
3
|
import type { AgentCallbacks } from "../config/types";
|
|
11
|
-
import { agentLoop } from "../config/runtime";
|
|
12
4
|
import { App, store } from "../tui/src";
|
|
13
5
|
import { render } from "ink";
|
|
14
6
|
import { AgentController } from "./agentController";
|
|
15
7
|
import { ACTIVE_PROVIDER_MODELS } from "../config/client";
|
|
16
8
|
import type { HomeScreenData } from "../tui/src/components/HomeScreen";
|
|
17
9
|
import { ensureProviderConfigured } from "../onboarding";
|
|
10
|
+
import { registerCommands } from "./slash";
|
|
18
11
|
|
|
19
12
|
export const agentCommand = new Command("agent")
|
|
20
13
|
.description("Runs the agent")
|
|
@@ -23,6 +16,9 @@ export const agentCommand = new Command("agent")
|
|
|
23
16
|
|
|
24
17
|
/** Runs the interactive agent from either `woopcode` or `woopcode agent`. */
|
|
25
18
|
export async function runAgent() {
|
|
19
|
+
// Register slash commands
|
|
20
|
+
registerCommands();
|
|
21
|
+
|
|
26
22
|
// Ensure provider is configured (launches onboarding if needed)
|
|
27
23
|
await ensureProviderConfigured();
|
|
28
24
|
|
|
@@ -32,48 +28,48 @@ export async function runAgent() {
|
|
|
32
28
|
const apiKey = config.providers[provider].apiKey;
|
|
33
29
|
|
|
34
30
|
const callbacks: AgentCallbacks = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
31
|
+
onStatus(status) {
|
|
32
|
+
if (cancelStatusTimeout) {
|
|
33
|
+
clearTimeout(cancelStatusTimeout);
|
|
34
|
+
cancelStatusTimeout = undefined;
|
|
35
|
+
}
|
|
36
|
+
store.setStatus(status);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
onToolStart(tool) {
|
|
40
|
+
store.finishAssistantMessage();
|
|
41
|
+
store.startTool(tool);
|
|
42
|
+
store.setStatus(`Running ${tool.name}...`);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
onToolFinish(tool) {
|
|
46
|
+
store.finishTool(tool.id);
|
|
47
|
+
store.setStatus("Thinking...");
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
onText(text) {
|
|
51
|
+
store.appendAssistantText(text);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
onDone() {
|
|
55
|
+
//console.log("onDone received");
|
|
56
|
+
store.finishAssistantMessage();
|
|
57
|
+
store.setStatus("Ready");
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
onError(error) {
|
|
61
|
+
store.finishAssistantMessage();
|
|
62
|
+
store.setStatus(`Error: ${error.message}`);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
onCancel() {
|
|
66
|
+
store.finishAssistantMessage();
|
|
67
|
+
store.setStatus("Cancelled");
|
|
68
|
+
|
|
69
|
+
cancelStatusTimeout = setTimeout(() => {
|
|
61
70
|
store.setStatus("Ready");
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
onError(error) {
|
|
65
|
-
store.finishAssistantMessage();
|
|
66
|
-
store.setStatus(`Error: ${error.message}`);
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
onCancel() {
|
|
70
|
-
store.finishAssistantMessage();
|
|
71
|
-
store.setStatus("Cancelled");
|
|
72
|
-
|
|
73
|
-
cancelStatusTimeout = setTimeout(() => {
|
|
74
|
-
store.setStatus("Ready");
|
|
75
|
-
}, 1000);
|
|
76
|
-
},
|
|
71
|
+
}, 1000);
|
|
72
|
+
},
|
|
77
73
|
};
|
|
78
74
|
const controller = new AgentController(provider, apiKey, callbacks);
|
|
79
75
|
await controller.initialize();
|
|
@@ -102,7 +98,8 @@ export async function runAgent() {
|
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
async function buildHomeScreen(provider: string): Promise<HomeScreenData> {
|
|
105
|
-
const repository =
|
|
101
|
+
const repository =
|
|
102
|
+
process.cwd().split("/").filter(Boolean).at(-1) ?? "workspace";
|
|
106
103
|
const branch = await getBranch();
|
|
107
104
|
const providerLabel = provider === "google" ? "Gemini" : titleCase(provider);
|
|
108
105
|
|
|
@@ -117,7 +114,15 @@ async function buildHomeScreen(provider: string): Promise<HomeScreenData> {
|
|
|
117
114
|
"Optimize performance",
|
|
118
115
|
"Add authentication",
|
|
119
116
|
],
|
|
120
|
-
capabilities: [
|
|
117
|
+
capabilities: [
|
|
118
|
+
"Build",
|
|
119
|
+
"Review",
|
|
120
|
+
"Explain",
|
|
121
|
+
"Refactor",
|
|
122
|
+
"Debug",
|
|
123
|
+
"Test",
|
|
124
|
+
"Document",
|
|
125
|
+
],
|
|
121
126
|
repository,
|
|
122
127
|
branch,
|
|
123
128
|
providerName: providerLabel,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Slash Commands
|
|
2
|
+
|
|
3
|
+
Local commands that execute instantly without invoking the LLM.
|
|
4
|
+
|
|
5
|
+
## Available Commands
|
|
6
|
+
|
|
7
|
+
### Session Commands
|
|
8
|
+
|
|
9
|
+
- **`/new`** (aliases: `clear`, `reset`) - Start a new conversation
|
|
10
|
+
- **`/exit`** (aliases: `quit`, `q`) - Exit Woopcode
|
|
11
|
+
|
|
12
|
+
### Configuration Commands
|
|
13
|
+
|
|
14
|
+
- **`/provider [name]`** (alias: `p`) - Show current provider or switch to another
|
|
15
|
+
- Without arguments: Shows current provider and all available providers
|
|
16
|
+
- With provider name: Switches to the specified provider (if configured)
|
|
17
|
+
|
|
18
|
+
- **`/login <provider> <api-key>`** - Login to a provider from within the app
|
|
19
|
+
- Example: `/login google YOUR_API_KEY`
|
|
20
|
+
- Validates the API key before saving
|
|
21
|
+
- Sets the provider as active
|
|
22
|
+
|
|
23
|
+
- **`/logout [provider]`** - Logout from a provider
|
|
24
|
+
- Without arguments: Logs out from current provider
|
|
25
|
+
- With provider name: Logs out from specified provider
|
|
26
|
+
- Automatically switches to another logged-in provider if available
|
|
27
|
+
|
|
28
|
+
- **`/model`** (alias: `m`) - Show current model and available models
|
|
29
|
+
- Currently read-only; model switching will be added in future versions
|
|
30
|
+
|
|
31
|
+
### Workspace Commands
|
|
32
|
+
|
|
33
|
+
- **`/workspace`** (alias: `ws`) - Show workspace information
|
|
34
|
+
- Displays: workspace name, path, git branch, file count
|
|
35
|
+
|
|
36
|
+
- **`/status`** (alias: `info`) - Show comprehensive system status
|
|
37
|
+
- Displays: workspace info, provider, model, conversation stats, tools, version
|
|
38
|
+
|
|
39
|
+
### Other Commands
|
|
40
|
+
|
|
41
|
+
- **`/help`** (aliases: `h`, `?`) - Show all available commands
|
|
42
|
+
- **`/version`** (alias: `v`) - Show Woopcode version
|
|
43
|
+
|
|
44
|
+
## Discovery Mode
|
|
45
|
+
|
|
46
|
+
Type `/` alone to see a quick list of all available commands.
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
The slash command system consists of:
|
|
51
|
+
|
|
52
|
+
- **Parser** (`parser.ts`) - Parses user input into commands and arguments
|
|
53
|
+
- **Registry** (`registry.ts`) - Manages command registration and lookup
|
|
54
|
+
- **Handler** (`handler.ts`) - Executes commands and handles errors
|
|
55
|
+
- **Commands** (`commands.ts`) - All command implementations
|
|
56
|
+
|
|
57
|
+
### Adding New Commands
|
|
58
|
+
|
|
59
|
+
1. Define command in `commands.ts`:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const myCommand: SlashCommand = {
|
|
63
|
+
name: "mycommand",
|
|
64
|
+
aliases: ["mc", "mycmd"],
|
|
65
|
+
description: "Does something useful",
|
|
66
|
+
category: "other", // session | configuration | workspace | other
|
|
67
|
+
|
|
68
|
+
async execute(context, args) {
|
|
69
|
+
// Implementation
|
|
70
|
+
return "Command output";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
2. Register in `registerCommands()`:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
export function registerCommands() {
|
|
79
|
+
// ... existing commands
|
|
80
|
+
registry.register(myCommand);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That's it! The command will automatically appear in `/help` and support tab completion.
|
|
85
|
+
|
|
86
|
+
## Integration
|
|
87
|
+
|
|
88
|
+
Slash commands are intercepted in `tui/src/prompt.tsx` before being sent to the agent:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const result = await handleSlashCommand(prompt, context);
|
|
92
|
+
|
|
93
|
+
if (result.handled) {
|
|
94
|
+
// Command was executed, don't send to LLM
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Otherwise continue to agent
|
|
99
|
+
await controller.run(prompt);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Testing
|
|
103
|
+
|
|
104
|
+
Tests are located in `packages/tests/slash/`:
|
|
105
|
+
|
|
106
|
+
- `parser.test.ts` - Parser logic
|
|
107
|
+
- `registry.test.ts` - Command registry
|
|
108
|
+
- `handler.test.ts` - Command execution
|
|
109
|
+
|
|
110
|
+
Run tests:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
bun test packages/tests/slash/
|
|
114
|
+
```
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { SlashCommand, SlashCommandContext } from "./types";
|
|
2
|
+
import { registry } from "./registry";
|
|
3
|
+
import {
|
|
4
|
+
getConfig,
|
|
5
|
+
saveConfig,
|
|
6
|
+
getConversation,
|
|
7
|
+
saveConversation,
|
|
8
|
+
} from "../../config/config";
|
|
9
|
+
|
|
10
|
+
// Read version from package.json
|
|
11
|
+
const packageJsonPath = `${import.meta.dir}/../../package.json`;
|
|
12
|
+
const packageJson = await Bun.file(packageJsonPath).json();
|
|
13
|
+
const version = packageJson.version as string;
|
|
14
|
+
|
|
15
|
+
// Read models from models.json
|
|
16
|
+
const modelsJsonPath = `${import.meta.dir}/../../config/models.json`;
|
|
17
|
+
const modelsData = await Bun.file(modelsJsonPath).json();
|
|
18
|
+
const models = modelsData as Array<{
|
|
19
|
+
id: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
name: string;
|
|
22
|
+
contextWindow: number | string;
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
// ==================== SESSION COMMANDS ====================
|
|
26
|
+
|
|
27
|
+
const helpCommand: SlashCommand = {
|
|
28
|
+
name: "help",
|
|
29
|
+
aliases: ["h", "?"],
|
|
30
|
+
description: "Show available commands",
|
|
31
|
+
category: "other",
|
|
32
|
+
|
|
33
|
+
async execute(context, args) {
|
|
34
|
+
return registry.generateHelp();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const newCommand: SlashCommand = {
|
|
39
|
+
name: "new",
|
|
40
|
+
aliases: ["clear", "reset"],
|
|
41
|
+
description: "Start a new conversation",
|
|
42
|
+
category: "session",
|
|
43
|
+
|
|
44
|
+
async execute(context, args) {
|
|
45
|
+
await saveConversation([]);
|
|
46
|
+
return "Started new conversation";
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const exitCommand: SlashCommand = {
|
|
51
|
+
name: "exit",
|
|
52
|
+
aliases: ["quit", "q"],
|
|
53
|
+
description: "Exit Woopcode",
|
|
54
|
+
category: "session",
|
|
55
|
+
|
|
56
|
+
async execute(context, args) {
|
|
57
|
+
await context.onExit();
|
|
58
|
+
return "Exiting...";
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ==================== CONFIGURATION COMMANDS ====================
|
|
63
|
+
|
|
64
|
+
const providerCommand: SlashCommand = {
|
|
65
|
+
name: "provider",
|
|
66
|
+
aliases: ["p"],
|
|
67
|
+
description: "Show or switch provider",
|
|
68
|
+
category: "configuration",
|
|
69
|
+
usage: "/provider [provider-name]",
|
|
70
|
+
|
|
71
|
+
async execute(context, args) {
|
|
72
|
+
const config = await getConfig();
|
|
73
|
+
|
|
74
|
+
if (args.length === 0) {
|
|
75
|
+
const current = config.defaultProvider;
|
|
76
|
+
const providers = Object.entries(config.providers)
|
|
77
|
+
.map(([name, details]: [string, any]) => {
|
|
78
|
+
const status = details.apiKey ? "✓" : "✗";
|
|
79
|
+
const active = name === current ? "(active)" : "";
|
|
80
|
+
return ` ${status} ${name} ${active}`;
|
|
81
|
+
})
|
|
82
|
+
.join("\n");
|
|
83
|
+
|
|
84
|
+
return `Current Provider: ${current}\n\nAvailable:\n${providers}\n\nTip: Use /login or /logout to manage authentication`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newProvider = args[0];
|
|
88
|
+
if (!newProvider) {
|
|
89
|
+
return `Provider name required.\nUsage: /provider <provider-name>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const available = Object.keys(config.providers);
|
|
93
|
+
|
|
94
|
+
if (!available.includes(newProvider)) {
|
|
95
|
+
return `Provider "${newProvider}" not found.\nAvailable: ${available.join(", ")}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const providerConfig = config.providers[newProvider];
|
|
99
|
+
if (!providerConfig || !providerConfig.apiKey) {
|
|
100
|
+
return `Provider "${newProvider}" not configured.\nUse: /login ${newProvider} <api-key>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
config.defaultProvider = newProvider;
|
|
104
|
+
await saveConfig(config);
|
|
105
|
+
|
|
106
|
+
return `Switched to: ${newProvider}`;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const modelCommand: SlashCommand = {
|
|
111
|
+
name: "model",
|
|
112
|
+
aliases: ["m"],
|
|
113
|
+
description: "Show current model and available models",
|
|
114
|
+
category: "configuration",
|
|
115
|
+
|
|
116
|
+
async execute(context, args) {
|
|
117
|
+
const config = await getConfig();
|
|
118
|
+
const provider = config.defaultProvider;
|
|
119
|
+
|
|
120
|
+
// Show current model (read-only for now)
|
|
121
|
+
let output = `Current Model: gemini-3.5-flash-lite\n\n`;
|
|
122
|
+
|
|
123
|
+
// List available models for current provider
|
|
124
|
+
const providerModels = models.filter((m) => m.provider === provider);
|
|
125
|
+
|
|
126
|
+
if (providerModels.length > 0) {
|
|
127
|
+
output += `Available models for ${provider}:\n`;
|
|
128
|
+
providerModels.forEach((m) => {
|
|
129
|
+
output += ` ${m.id} - ${m.name}\n`;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return output.trim();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const loginCommand: SlashCommand = {
|
|
138
|
+
name: "login",
|
|
139
|
+
description: "Login to a provider",
|
|
140
|
+
category: "configuration",
|
|
141
|
+
usage: "/login <provider> <api-key>",
|
|
142
|
+
|
|
143
|
+
async execute(context, args) {
|
|
144
|
+
if (args.length < 2) {
|
|
145
|
+
return `Usage: /login <provider> <api-key>\nExample: /login google YOUR_API_KEY`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const provider = args[0];
|
|
149
|
+
const apiKey = args.slice(1).join(" "); // Allow API keys with spaces
|
|
150
|
+
|
|
151
|
+
const config = await getConfig();
|
|
152
|
+
|
|
153
|
+
if (!config.providers[provider]) {
|
|
154
|
+
const available = Object.keys(config.providers);
|
|
155
|
+
return `Unknown provider "${provider}".\nAvailable: ${available.join(", ")}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate API key
|
|
159
|
+
const { loginProvider } = await import("../../config/authProvider");
|
|
160
|
+
const isValid = await loginProvider(provider, apiKey);
|
|
161
|
+
|
|
162
|
+
if (!isValid) {
|
|
163
|
+
return `Invalid API key for ${provider}.\nPlease check your API key and try again.`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Save the API key
|
|
167
|
+
config.providers[provider].apiKey = apiKey;
|
|
168
|
+
config.defaultProvider = provider;
|
|
169
|
+
await saveConfig(config);
|
|
170
|
+
|
|
171
|
+
return `Successfully logged in to ${provider}!\nThis is now your active provider.`;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const logoutCommand: SlashCommand = {
|
|
176
|
+
name: "logout",
|
|
177
|
+
description: "Logout from a provider",
|
|
178
|
+
category: "configuration",
|
|
179
|
+
usage: "/logout [provider]",
|
|
180
|
+
|
|
181
|
+
async execute(context, args) {
|
|
182
|
+
const config = await getConfig();
|
|
183
|
+
|
|
184
|
+
// If no provider specified, logout from current
|
|
185
|
+
const provider = args[0] || config.defaultProvider;
|
|
186
|
+
|
|
187
|
+
if (!config.providers[provider]) {
|
|
188
|
+
const available = Object.keys(config.providers);
|
|
189
|
+
return `Unknown provider "${provider}".\nAvailable: ${available.join(", ")}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const providerConfig = config.providers[provider];
|
|
193
|
+
if (!providerConfig?.apiKey) {
|
|
194
|
+
return `Already logged out from ${provider}.`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove API key
|
|
198
|
+
delete config.providers[provider].apiKey;
|
|
199
|
+
|
|
200
|
+
// If logging out from default provider, clear default
|
|
201
|
+
if (config.defaultProvider === provider) {
|
|
202
|
+
// Find another logged-in provider
|
|
203
|
+
const otherProvider = Object.entries(config.providers).find(
|
|
204
|
+
([name, details]: [string, any]) => name !== provider && details.apiKey
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (otherProvider) {
|
|
208
|
+
config.defaultProvider = otherProvider[0];
|
|
209
|
+
} else {
|
|
210
|
+
config.defaultProvider = "";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await saveConfig(config);
|
|
215
|
+
|
|
216
|
+
const nextProvider = config.defaultProvider
|
|
217
|
+
? `\nActive provider: ${config.defaultProvider}`
|
|
218
|
+
: "\nNo providers logged in. Use /login to authenticate.";
|
|
219
|
+
|
|
220
|
+
return `Logged out from ${provider}.${nextProvider}`;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// ==================== WORKSPACE COMMANDS ====================
|
|
225
|
+
|
|
226
|
+
const workspaceCommand: SlashCommand = {
|
|
227
|
+
name: "workspace",
|
|
228
|
+
aliases: ["ws"],
|
|
229
|
+
description: "Show workspace information",
|
|
230
|
+
category: "workspace",
|
|
231
|
+
|
|
232
|
+
async execute(context, args) {
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
235
|
+
const repoName = parts[parts.length - 1] ?? "unknown";
|
|
236
|
+
|
|
237
|
+
let branch = "not a git repository";
|
|
238
|
+
try {
|
|
239
|
+
branch =
|
|
240
|
+
(await Bun.$`git branch --show-current`.text()).trim() || "detached";
|
|
241
|
+
} catch {}
|
|
242
|
+
|
|
243
|
+
let fileCount = 0;
|
|
244
|
+
try {
|
|
245
|
+
for await (const entry of new Bun.Glob("**/*").scan(cwd)) {
|
|
246
|
+
if (
|
|
247
|
+
!entry.startsWith("node_modules") &&
|
|
248
|
+
!entry.startsWith(".git") &&
|
|
249
|
+
!entry.startsWith("dist")
|
|
250
|
+
) {
|
|
251
|
+
fileCount++;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
return [
|
|
257
|
+
`Workspace: ${repoName}`,
|
|
258
|
+
`Path: ${cwd}`,
|
|
259
|
+
`Branch: ${branch}`,
|
|
260
|
+
`Files: ${fileCount > 0 ? fileCount : "counting..."}`,
|
|
261
|
+
].join("\n");
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const statusCommand: SlashCommand = {
|
|
266
|
+
name: "status",
|
|
267
|
+
aliases: ["info"],
|
|
268
|
+
description: "Show comprehensive system status",
|
|
269
|
+
category: "other",
|
|
270
|
+
|
|
271
|
+
async execute(context, args) {
|
|
272
|
+
const config = await getConfig();
|
|
273
|
+
const conversation = await getConversation();
|
|
274
|
+
const cwd = process.cwd();
|
|
275
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
276
|
+
const repoName = parts[parts.length - 1] ?? "workspace";
|
|
277
|
+
|
|
278
|
+
let branch = "not a git repository";
|
|
279
|
+
try {
|
|
280
|
+
branch =
|
|
281
|
+
(await Bun.$`git branch --show-current`.text()).trim() || "detached";
|
|
282
|
+
} catch {}
|
|
283
|
+
|
|
284
|
+
const provider = config.defaultProvider;
|
|
285
|
+
const providerLabel = provider === "google" ? "Google Gemini" : provider;
|
|
286
|
+
|
|
287
|
+
return [
|
|
288
|
+
`Workspace: ${repoName}`,
|
|
289
|
+
`Path: ${cwd}`,
|
|
290
|
+
`Branch: ${branch}`,
|
|
291
|
+
``,
|
|
292
|
+
`Provider: ${providerLabel}`,
|
|
293
|
+
`Model: gemini-3.5-flash-lite`,
|
|
294
|
+
``,
|
|
295
|
+
`Conversation: ${conversation.length} messages`,
|
|
296
|
+
`Tools: 9 registered`,
|
|
297
|
+
`Version: ${version}`,
|
|
298
|
+
].join("\n");
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ==================== OTHER COMMANDS ====================
|
|
303
|
+
|
|
304
|
+
const versionCommand: SlashCommand = {
|
|
305
|
+
name: "version",
|
|
306
|
+
aliases: ["v"],
|
|
307
|
+
description: "Show Woopcode version",
|
|
308
|
+
category: "other",
|
|
309
|
+
|
|
310
|
+
async execute(context, args) {
|
|
311
|
+
return `Woopcode v${version}`;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// ==================== REGISTRATION ====================
|
|
316
|
+
|
|
317
|
+
export function registerCommands() {
|
|
318
|
+
registry.register(helpCommand);
|
|
319
|
+
registry.register(newCommand);
|
|
320
|
+
registry.register(exitCommand);
|
|
321
|
+
registry.register(providerCommand);
|
|
322
|
+
registry.register(loginCommand);
|
|
323
|
+
registry.register(logoutCommand);
|
|
324
|
+
registry.register(modelCommand);
|
|
325
|
+
registry.register(workspaceCommand);
|
|
326
|
+
registry.register(statusCommand);
|
|
327
|
+
registry.register(versionCommand);
|
|
328
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseInput } from "./parser";
|
|
2
|
+
import { registry } from "./registry";
|
|
3
|
+
import type { SlashCommandContext } from "./types";
|
|
4
|
+
|
|
5
|
+
export async function handleSlashCommand(
|
|
6
|
+
input: string,
|
|
7
|
+
context: SlashCommandContext,
|
|
8
|
+
): Promise<{ handled: boolean; output?: string }> {
|
|
9
|
+
const parsed = parseInput(input);
|
|
10
|
+
|
|
11
|
+
// Discovery mode: show available commands
|
|
12
|
+
if (parsed.type === "discovery") {
|
|
13
|
+
const output = registry.generateDiscoveryList();
|
|
14
|
+
context.onOutput(output);
|
|
15
|
+
return { handled: true, output };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (parsed.type !== "command") {
|
|
19
|
+
return { handled: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const command = registry.get(parsed.command!);
|
|
23
|
+
|
|
24
|
+
if (!command) {
|
|
25
|
+
const output = `Unknown command "/${parsed.command}"\nRun /help to see available commands.`;
|
|
26
|
+
context.onOutput(output);
|
|
27
|
+
return { handled: true, output };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const output = await command.execute(context, parsed.args!);
|
|
32
|
+
context.onOutput(output);
|
|
33
|
+
return { handled: true, output };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
const output = `Error: ${message}`;
|
|
37
|
+
context.onOutput(output);
|
|
38
|
+
return { handled: true, output };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { handleSlashCommand } from "./handler";
|
|
2
|
+
export { registry } from "./registry";
|
|
3
|
+
export { registerCommands } from "./commands";
|
|
4
|
+
export { parseInput } from "./parser";
|
|
5
|
+
export type {
|
|
6
|
+
SlashCommand,
|
|
7
|
+
SlashCommandContext,
|
|
8
|
+
ParsedCommand,
|
|
9
|
+
} from "./types";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ParsedCommand } from "./types";
|
|
2
|
+
|
|
3
|
+
export function parseInput(input: string): ParsedCommand {
|
|
4
|
+
const trimmed = input.trim();
|
|
5
|
+
|
|
6
|
+
//discovery mode
|
|
7
|
+
if (trimmed === "/") {
|
|
8
|
+
return { type: "discovery", originalInput: input };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!trimmed.startsWith("/")) {
|
|
12
|
+
return { type: "text", originalInput: input };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parts = trimmed.slice(1).split(/\s+/);
|
|
16
|
+
const command = parts[0]?.toLowerCase() || "";
|
|
17
|
+
const args = parts.slice(1);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
type: "command",
|
|
21
|
+
command,
|
|
22
|
+
args,
|
|
23
|
+
originalInput: input,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { SlashCommand } from "./types";
|
|
2
|
+
|
|
3
|
+
export class SlashCommandRegistry {
|
|
4
|
+
private commands = new Map<string, SlashCommand>();
|
|
5
|
+
private aliases = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
register(command: SlashCommand): void {
|
|
8
|
+
this.commands.set(command.name, command);
|
|
9
|
+
|
|
10
|
+
if (command.aliases) {
|
|
11
|
+
command.aliases.forEach((alias) => {
|
|
12
|
+
this.aliases.set(alias, command.name);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get(nameOrAlias: string): SlashCommand | undefined {
|
|
18
|
+
const name = this.aliases.get(nameOrAlias) ?? nameOrAlias;
|
|
19
|
+
return this.commands.get(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getAll(): SlashCommand[] {
|
|
23
|
+
return Array.from(this.commands.values());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getByCategory(category: string): SlashCommand[] {
|
|
27
|
+
return this.getAll().filter((cmd) => cmd.category === category);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Auto-generated help
|
|
31
|
+
generateHelp(): string {
|
|
32
|
+
const categories = {
|
|
33
|
+
session: "Session",
|
|
34
|
+
configuration: "Configuration",
|
|
35
|
+
workspace: "Workspace",
|
|
36
|
+
other: "Other",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let output = "Available Commands:\n\n";
|
|
40
|
+
|
|
41
|
+
for (const [key, label] of Object.entries(categories)) {
|
|
42
|
+
const commands = this.getByCategory(key);
|
|
43
|
+
if (commands.length === 0) continue;
|
|
44
|
+
|
|
45
|
+
output += `${label}:\n`;
|
|
46
|
+
commands.forEach((cmd) => {
|
|
47
|
+
const aliases = cmd.aliases?.length
|
|
48
|
+
? ` (${cmd.aliases.join(", ")})`
|
|
49
|
+
: "";
|
|
50
|
+
output += ` /${cmd.name}${aliases} - ${cmd.description}\n`;
|
|
51
|
+
});
|
|
52
|
+
output += "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return output.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 🔥 Discovery list
|
|
59
|
+
generateDiscoveryList(): string {
|
|
60
|
+
return this.getAll()
|
|
61
|
+
.map((cmd) => `/${cmd.name}`)
|
|
62
|
+
.join(" ");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const registry = new SlashCommandRegistry();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentController } from "../agentController";
|
|
2
|
+
|
|
3
|
+
export interface SlashCommandContext {
|
|
4
|
+
controller: AgentController;
|
|
5
|
+
onExit: () => Promise<void>;
|
|
6
|
+
onOutput: (message: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SlashCommand {
|
|
10
|
+
name: string;
|
|
11
|
+
aliases?: string[];
|
|
12
|
+
description: string;
|
|
13
|
+
category: "session" | "configuration" | "workspace" | "other";
|
|
14
|
+
usage?: string;
|
|
15
|
+
execute(context: SlashCommandContext, args: string[]): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedCommand {
|
|
19
|
+
type: "command" | "text" | "discovery";
|
|
20
|
+
command?: string;
|
|
21
|
+
args?: string[];
|
|
22
|
+
originalInput?: string;
|
|
23
|
+
}
|
package/package.json
CHANGED
package/tui/src/prompt.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import { Box, Text, useInput } from "ink";
|
|
|
2
2
|
import TextInput from "ink-text-input";
|
|
3
3
|
import { useRef } from "react";
|
|
4
4
|
import type { AgentController } from "../../commands/agentController";
|
|
5
|
+
import { handleSlashCommand } from "../../commands/slash";
|
|
6
|
+
import { store } from "./store/ui-store";
|
|
5
7
|
|
|
6
8
|
interface PromptProps {
|
|
7
9
|
controller: AgentController;
|
|
@@ -42,6 +44,24 @@ export function Prompt({
|
|
|
42
44
|
if (!prompt || controller.isBusy()) {
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
47
|
+
|
|
48
|
+
// 🔥 Slash command interception
|
|
49
|
+
const context = {
|
|
50
|
+
controller,
|
|
51
|
+
onExit,
|
|
52
|
+
onOutput: (message: string) => {
|
|
53
|
+
store.addSystemMessage(message);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result = await handleSlashCommand(prompt, context);
|
|
58
|
+
|
|
59
|
+
if (result.handled) {
|
|
60
|
+
onValueChange("");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Original flow
|
|
45
65
|
if (prompt === "/exit") {
|
|
46
66
|
await onExit();
|
|
47
67
|
return;
|
|
@@ -59,6 +59,22 @@ export class UIStore {
|
|
|
59
59
|
this.emit();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
addSystemMessage(content: string) {
|
|
63
|
+
this.state = {
|
|
64
|
+
...this.state,
|
|
65
|
+
timeline: [
|
|
66
|
+
...this.state.timeline,
|
|
67
|
+
{
|
|
68
|
+
id: crypto.randomUUID(),
|
|
69
|
+
type: "system",
|
|
70
|
+
content,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.emit();
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
startTool(tool: ToolCall) {
|
|
63
79
|
this.state = {
|
|
64
80
|
...this.state,
|
package/tui/src/timeline.tsx
CHANGED
|
@@ -47,6 +47,18 @@ function TimelineItem({ item }: { item: TimeLineItem }) {
|
|
|
47
47
|
</Box>
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
+
case "system":
|
|
51
|
+
return (
|
|
52
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
53
|
+
<Text bold color="yellow">
|
|
54
|
+
System
|
|
55
|
+
</Text>
|
|
56
|
+
<Box paddingLeft={1}>
|
|
57
|
+
<Text>{item.content}</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
|
|
50
62
|
case "tool": {
|
|
51
63
|
const label = toolLabel(item.name);
|
|
52
64
|
const target = formatToolArgument(item.arguments);
|