ctb 1.1.0 → 1.2.1
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/README.md +42 -11
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +12 -0
- package/src/__tests__/session.test.ts +399 -0
- package/src/bot.ts +27 -3
- package/src/cli.ts +94 -0
- package/src/handlers/commands.ts +383 -50
- package/src/handlers/index.ts +8 -1
- package/src/index.ts +19 -2
- package/src/session.ts +140 -6
package/README.md
CHANGED
|
@@ -38,12 +38,15 @@ To achieve this, I set up a folder with a CLAUDE.md that teaches Claude about me
|
|
|
38
38
|
# Install globally
|
|
39
39
|
npm install -g ctb
|
|
40
40
|
|
|
41
|
+
# Show setup tutorial
|
|
42
|
+
ctb tut
|
|
43
|
+
|
|
41
44
|
# Run in any project directory
|
|
42
45
|
cd ~/my-project
|
|
43
46
|
ctb
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`.
|
|
49
|
+
On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`. Run `ctb tut` for a step-by-step setup guide.
|
|
47
50
|
|
|
48
51
|
**Run multiple instances:** Each project directory gets its own isolated bot session. Open multiple terminals and run `ctb` in different directories.
|
|
49
52
|
|
|
@@ -102,8 +105,17 @@ new - Start a fresh session
|
|
|
102
105
|
resume - Resume last session
|
|
103
106
|
stop - Interrupt current query
|
|
104
107
|
status - Check what Claude is doing
|
|
108
|
+
model - Switch model (sonnet, opus, haiku)
|
|
109
|
+
cost - Show token usage and estimated cost
|
|
110
|
+
think - Force thinking mode
|
|
111
|
+
plan - Toggle planning mode
|
|
112
|
+
compact - Trigger context compaction
|
|
113
|
+
undo - Revert file changes to last checkpoint
|
|
105
114
|
cd - Change working directory
|
|
115
|
+
skill - Invoke a Claude Code skill
|
|
116
|
+
file - Download a file
|
|
106
117
|
bookmarks - Manage directory bookmarks
|
|
118
|
+
retry - Retry last message
|
|
107
119
|
restart - Restart the bot
|
|
108
120
|
```
|
|
109
121
|
|
|
@@ -148,16 +160,35 @@ The bot includes a built-in `ask_user` MCP server that lets Claude present optio
|
|
|
148
160
|
|
|
149
161
|
## Bot Commands
|
|
150
162
|
|
|
151
|
-
| Command
|
|
152
|
-
|
|
|
153
|
-
| `/start`
|
|
154
|
-
| `/new`
|
|
155
|
-
| `/resume`
|
|
156
|
-
| `/stop`
|
|
157
|
-
| `/status`
|
|
158
|
-
| `/
|
|
159
|
-
| `/
|
|
160
|
-
| `/
|
|
163
|
+
| Command | Description |
|
|
164
|
+
| --------------- | ---------------------------------------------------------- |
|
|
165
|
+
| `/start` | Show status and your user ID |
|
|
166
|
+
| `/new` | Start a fresh session |
|
|
167
|
+
| `/resume` | Resume last session after restart |
|
|
168
|
+
| `/stop` | Interrupt current query (aliases: `/c`, `/kill`, `/dc`) |
|
|
169
|
+
| `/status` | Check what Claude is doing |
|
|
170
|
+
| `/model <name>` | Switch model: sonnet, opus, haiku |
|
|
171
|
+
| `/cost` | Show token usage and estimated cost |
|
|
172
|
+
| `/think [lvl]` | Force thinking: off, normal, deep (default) |
|
|
173
|
+
| `/plan` | Toggle planning mode (no tool execution) |
|
|
174
|
+
| `/compact` | Trigger context compaction |
|
|
175
|
+
| `/undo` | Revert file changes to last checkpoint |
|
|
176
|
+
| `/cd <path>` | Change working directory |
|
|
177
|
+
| `/skill <name>` | Invoke a Claude Code skill (e.g., `/skill commit`) |
|
|
178
|
+
| `/file [path]` | Download file (auto-detects from last response if no path) |
|
|
179
|
+
| `/bookmarks` | Manage directory bookmarks |
|
|
180
|
+
| `/retry` | Retry last message |
|
|
181
|
+
| `/restart` | Restart the bot |
|
|
182
|
+
|
|
183
|
+
### Shell Commands
|
|
184
|
+
|
|
185
|
+
Prefix any message with `!` to run it as a shell command in the working directory:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
!ls -la
|
|
189
|
+
!git status
|
|
190
|
+
!pwd
|
|
191
|
+
```
|
|
161
192
|
|
|
162
193
|
### Directory Navigation
|
|
163
194
|
|
package/package.json
CHANGED
|
@@ -180,6 +180,7 @@ describe("CLI argument parsing", () => {
|
|
|
180
180
|
dir?: string;
|
|
181
181
|
help?: boolean;
|
|
182
182
|
version?: boolean;
|
|
183
|
+
tut?: boolean;
|
|
183
184
|
} {
|
|
184
185
|
const options: {
|
|
185
186
|
token?: string;
|
|
@@ -187,6 +188,7 @@ describe("CLI argument parsing", () => {
|
|
|
187
188
|
dir?: string;
|
|
188
189
|
help?: boolean;
|
|
189
190
|
version?: boolean;
|
|
191
|
+
tut?: boolean;
|
|
190
192
|
} = {};
|
|
191
193
|
|
|
192
194
|
for (const arg of args) {
|
|
@@ -194,6 +196,8 @@ describe("CLI argument parsing", () => {
|
|
|
194
196
|
options.help = true;
|
|
195
197
|
} else if (arg === "--version" || arg === "-v") {
|
|
196
198
|
options.version = true;
|
|
199
|
+
} else if (arg === "tut" || arg === "tutorial") {
|
|
200
|
+
options.tut = true;
|
|
197
201
|
} else if (arg.startsWith("--token=")) {
|
|
198
202
|
options.token = arg.slice(8);
|
|
199
203
|
} else if (arg.startsWith("--users=")) {
|
|
@@ -222,6 +226,14 @@ describe("CLI argument parsing", () => {
|
|
|
222
226
|
expect(parseArgs(["-v"])).toEqual({ version: true });
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
test("parses tut command", () => {
|
|
230
|
+
expect(parseArgs(["tut"])).toEqual({ tut: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("parses tutorial command", () => {
|
|
234
|
+
expect(parseArgs(["tutorial"])).toEqual({ tut: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
225
237
|
test("parses --token option", () => {
|
|
226
238
|
const result = parseArgs(["--token=my-bot-token"]);
|
|
227
239
|
expect(result.token).toBe("my-bot-token");
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for session module - model, cost, thinking, and plan mode features.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { ClaudeSession, getThinkingLevel } from "../session";
|
|
7
|
+
|
|
8
|
+
describe("getThinkingLevel", () => {
|
|
9
|
+
// Default keywords from config.ts:
|
|
10
|
+
// THINKING_KEYWORDS: "think,pensa,ragiona"
|
|
11
|
+
// THINKING_DEEP_KEYWORDS: "ultrathink,think hard,pensa bene"
|
|
12
|
+
|
|
13
|
+
describe("deep thinking triggers", () => {
|
|
14
|
+
test("triggers on 'think hard'", () => {
|
|
15
|
+
expect(getThinkingLevel("Please think hard about this")).toBe(50000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("triggers on 'ultrathink'", () => {
|
|
19
|
+
expect(getThinkingLevel("ultrathink about the problem")).toBe(50000);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("triggers on 'pensa bene' (Italian)", () => {
|
|
23
|
+
expect(getThinkingLevel("pensa bene prima di rispondere")).toBe(50000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("is case insensitive", () => {
|
|
27
|
+
expect(getThinkingLevel("THINK HARD about this")).toBe(50000);
|
|
28
|
+
expect(getThinkingLevel("ULTRATHINK")).toBe(50000);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("normal thinking triggers", () => {
|
|
33
|
+
test("triggers on 'think'", () => {
|
|
34
|
+
expect(getThinkingLevel("Let me think about this")).toBe(10000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("triggers on 'pensa' (Italian)", () => {
|
|
38
|
+
expect(getThinkingLevel("pensa a questo problema")).toBe(10000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("triggers on 'ragiona' (Italian)", () => {
|
|
42
|
+
expect(getThinkingLevel("ragiona sul codice")).toBe(10000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("is case insensitive", () => {
|
|
46
|
+
expect(getThinkingLevel("THINK about it")).toBe(10000);
|
|
47
|
+
expect(getThinkingLevel("PENSA attentamente")).toBe(10000);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("no thinking triggers", () => {
|
|
52
|
+
test("returns 0 for simple messages", () => {
|
|
53
|
+
expect(getThinkingLevel("Hello")).toBe(0);
|
|
54
|
+
expect(getThinkingLevel("What time is it?")).toBe(0);
|
|
55
|
+
expect(getThinkingLevel("Show me the file")).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns 0 for empty string", () => {
|
|
59
|
+
expect(getThinkingLevel("")).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns 0 for unrelated words", () => {
|
|
63
|
+
expect(getThinkingLevel("analyze this code")).toBe(0);
|
|
64
|
+
expect(getThinkingLevel("consider the options")).toBe(0);
|
|
65
|
+
expect(getThinkingLevel("evaluate this approach")).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("priority", () => {
|
|
70
|
+
test("deep triggers take precedence over normal", () => {
|
|
71
|
+
// "think hard" should trigger deep (50000), not normal "think" (10000)
|
|
72
|
+
expect(getThinkingLevel("think hard")).toBe(50000);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("ClaudeSession", () => {
|
|
78
|
+
let session: ClaudeSession;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
session = new ClaudeSession();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("model selection", () => {
|
|
85
|
+
test("defaults to sonnet", () => {
|
|
86
|
+
expect(session.currentModel).toBe("sonnet");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("can be set to opus", () => {
|
|
90
|
+
session.currentModel = "opus";
|
|
91
|
+
expect(session.currentModel).toBe("opus");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("can be set to haiku", () => {
|
|
95
|
+
session.currentModel = "haiku";
|
|
96
|
+
expect(session.currentModel).toBe("haiku");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("can switch between models", () => {
|
|
100
|
+
session.currentModel = "opus";
|
|
101
|
+
expect(session.currentModel).toBe("opus");
|
|
102
|
+
|
|
103
|
+
session.currentModel = "haiku";
|
|
104
|
+
expect(session.currentModel).toBe("haiku");
|
|
105
|
+
|
|
106
|
+
session.currentModel = "sonnet";
|
|
107
|
+
expect(session.currentModel).toBe("sonnet");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("forceThinking", () => {
|
|
112
|
+
test("defaults to null", () => {
|
|
113
|
+
expect(session.forceThinking).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("can be set to specific token count", () => {
|
|
117
|
+
session.forceThinking = 10000;
|
|
118
|
+
expect(session.forceThinking).toBe(10000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("can be set to 0 (off)", () => {
|
|
122
|
+
session.forceThinking = 0;
|
|
123
|
+
expect(session.forceThinking).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("can be set to deep (50000)", () => {
|
|
127
|
+
session.forceThinking = 50000;
|
|
128
|
+
expect(session.forceThinking).toBe(50000);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("planMode", () => {
|
|
133
|
+
test("defaults to false", () => {
|
|
134
|
+
expect(session.planMode).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("can be toggled on", () => {
|
|
138
|
+
session.planMode = true;
|
|
139
|
+
expect(session.planMode).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("can be toggled off", () => {
|
|
143
|
+
session.planMode = true;
|
|
144
|
+
session.planMode = false;
|
|
145
|
+
expect(session.planMode).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("token usage tracking", () => {
|
|
150
|
+
test("starts with zero totals", () => {
|
|
151
|
+
expect(session.totalInputTokens).toBe(0);
|
|
152
|
+
expect(session.totalOutputTokens).toBe(0);
|
|
153
|
+
expect(session.totalCacheReadTokens).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("can accumulate input tokens", () => {
|
|
157
|
+
session.totalInputTokens += 1000;
|
|
158
|
+
session.totalInputTokens += 500;
|
|
159
|
+
expect(session.totalInputTokens).toBe(1500);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("can accumulate output tokens", () => {
|
|
163
|
+
session.totalOutputTokens += 2000;
|
|
164
|
+
session.totalOutputTokens += 800;
|
|
165
|
+
expect(session.totalOutputTokens).toBe(2800);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("can accumulate cache read tokens", () => {
|
|
169
|
+
session.totalCacheReadTokens += 500;
|
|
170
|
+
session.totalCacheReadTokens += 300;
|
|
171
|
+
expect(session.totalCacheReadTokens).toBe(800);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("estimateCost", () => {
|
|
176
|
+
describe("sonnet pricing", () => {
|
|
177
|
+
test("calculates zero cost for zero tokens", () => {
|
|
178
|
+
const cost = session.estimateCost();
|
|
179
|
+
expect(cost.inputCost).toBe(0);
|
|
180
|
+
expect(cost.outputCost).toBe(0);
|
|
181
|
+
expect(cost.total).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("calculates cost for 1M input tokens", () => {
|
|
185
|
+
session.totalInputTokens = 1_000_000;
|
|
186
|
+
const cost = session.estimateCost();
|
|
187
|
+
expect(cost.inputCost).toBe(3); // $3 per 1M input
|
|
188
|
+
expect(cost.outputCost).toBe(0);
|
|
189
|
+
expect(cost.total).toBe(3);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("calculates cost for 1M output tokens", () => {
|
|
193
|
+
session.totalOutputTokens = 1_000_000;
|
|
194
|
+
const cost = session.estimateCost();
|
|
195
|
+
expect(cost.inputCost).toBe(0);
|
|
196
|
+
expect(cost.outputCost).toBe(15); // $15 per 1M output
|
|
197
|
+
expect(cost.total).toBe(15);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("calculates combined cost", () => {
|
|
201
|
+
session.totalInputTokens = 500_000;
|
|
202
|
+
session.totalOutputTokens = 100_000;
|
|
203
|
+
const cost = session.estimateCost();
|
|
204
|
+
expect(cost.inputCost).toBe(1.5); // $3 * 0.5
|
|
205
|
+
expect(cost.outputCost).toBe(1.5); // $15 * 0.1
|
|
206
|
+
expect(cost.total).toBe(3);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("opus pricing", () => {
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
session.currentModel = "opus";
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("calculates cost for 1M input tokens", () => {
|
|
216
|
+
session.totalInputTokens = 1_000_000;
|
|
217
|
+
const cost = session.estimateCost();
|
|
218
|
+
expect(cost.inputCost).toBe(15); // $15 per 1M input
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("calculates cost for 1M output tokens", () => {
|
|
222
|
+
session.totalOutputTokens = 1_000_000;
|
|
223
|
+
const cost = session.estimateCost();
|
|
224
|
+
expect(cost.outputCost).toBe(75); // $75 per 1M output
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("calculates combined cost", () => {
|
|
228
|
+
session.totalInputTokens = 100_000;
|
|
229
|
+
session.totalOutputTokens = 50_000;
|
|
230
|
+
const cost = session.estimateCost();
|
|
231
|
+
expect(cost.inputCost).toBe(1.5); // $15 * 0.1
|
|
232
|
+
expect(cost.outputCost).toBe(3.75); // $75 * 0.05
|
|
233
|
+
expect(cost.total).toBe(5.25);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("haiku pricing", () => {
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
session.currentModel = "haiku";
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("calculates cost for 1M input tokens", () => {
|
|
243
|
+
session.totalInputTokens = 1_000_000;
|
|
244
|
+
const cost = session.estimateCost();
|
|
245
|
+
expect(cost.inputCost).toBe(0.25); // $0.25 per 1M input
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("calculates cost for 1M output tokens", () => {
|
|
249
|
+
session.totalOutputTokens = 1_000_000;
|
|
250
|
+
const cost = session.estimateCost();
|
|
251
|
+
expect(cost.outputCost).toBe(1.25); // $1.25 per 1M output
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("calculates combined cost", () => {
|
|
255
|
+
session.totalInputTokens = 4_000_000;
|
|
256
|
+
session.totalOutputTokens = 800_000;
|
|
257
|
+
const cost = session.estimateCost();
|
|
258
|
+
expect(cost.inputCost).toBe(1); // $0.25 * 4
|
|
259
|
+
expect(cost.outputCost).toBe(1); // $1.25 * 0.8
|
|
260
|
+
expect(cost.total).toBe(2);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("small token counts", () => {
|
|
265
|
+
test("calculates fractional costs", () => {
|
|
266
|
+
session.totalInputTokens = 1000;
|
|
267
|
+
session.totalOutputTokens = 500;
|
|
268
|
+
const cost = session.estimateCost();
|
|
269
|
+
expect(cost.inputCost).toBeCloseTo(0.003, 5); // $3 * 0.001
|
|
270
|
+
expect(cost.outputCost).toBeCloseTo(0.0075, 5); // $15 * 0.0005
|
|
271
|
+
expect(cost.total).toBeCloseTo(0.0105, 5);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("kill", () => {
|
|
277
|
+
test("resets sessionId", async () => {
|
|
278
|
+
session.sessionId = "test-session-123";
|
|
279
|
+
await session.kill();
|
|
280
|
+
expect(session.sessionId).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("resets lastActivity", async () => {
|
|
284
|
+
session.lastActivity = new Date();
|
|
285
|
+
await session.kill();
|
|
286
|
+
expect(session.lastActivity).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("resets token totals", async () => {
|
|
290
|
+
session.totalInputTokens = 10000;
|
|
291
|
+
session.totalOutputTokens = 5000;
|
|
292
|
+
session.totalCacheReadTokens = 2000;
|
|
293
|
+
await session.kill();
|
|
294
|
+
expect(session.totalInputTokens).toBe(0);
|
|
295
|
+
expect(session.totalOutputTokens).toBe(0);
|
|
296
|
+
expect(session.totalCacheReadTokens).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("resets planMode", async () => {
|
|
300
|
+
session.planMode = true;
|
|
301
|
+
await session.kill();
|
|
302
|
+
expect(session.planMode).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("resets forceThinking", async () => {
|
|
306
|
+
session.forceThinking = 50000;
|
|
307
|
+
await session.kill();
|
|
308
|
+
expect(session.forceThinking).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("preserves currentModel", async () => {
|
|
312
|
+
session.currentModel = "opus";
|
|
313
|
+
await session.kill();
|
|
314
|
+
expect(session.currentModel).toBe("opus");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("isActive", () => {
|
|
319
|
+
test("returns false when no sessionId", () => {
|
|
320
|
+
expect(session.isActive).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("returns true when sessionId exists", () => {
|
|
324
|
+
session.sessionId = "test-session-123";
|
|
325
|
+
expect(session.isActive).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("workingDir", () => {
|
|
330
|
+
test("can get working directory", () => {
|
|
331
|
+
expect(typeof session.workingDir).toBe("string");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("setWorkingDir changes directory", () => {
|
|
335
|
+
const newDir = "/tmp/test-dir";
|
|
336
|
+
session.setWorkingDir(newDir);
|
|
337
|
+
expect(session.workingDir).toBe(newDir);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("setWorkingDir clears sessionId", () => {
|
|
341
|
+
session.sessionId = "test-session-123";
|
|
342
|
+
session.setWorkingDir("/tmp/new-dir");
|
|
343
|
+
expect(session.sessionId).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("stop", () => {
|
|
348
|
+
test("returns false when nothing is running", async () => {
|
|
349
|
+
const result = await session.stop();
|
|
350
|
+
expect(result).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("interrupt flags", () => {
|
|
355
|
+
test("markInterrupt and consumeInterruptFlag work together", () => {
|
|
356
|
+
expect(session.consumeInterruptFlag()).toBe(false);
|
|
357
|
+
session.markInterrupt();
|
|
358
|
+
expect(session.consumeInterruptFlag()).toBe(true);
|
|
359
|
+
expect(session.consumeInterruptFlag()).toBe(false); // Consumed
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("processing state", () => {
|
|
364
|
+
test("startProcessing returns cleanup function", () => {
|
|
365
|
+
expect(session.isRunning).toBe(false);
|
|
366
|
+
const cleanup = session.startProcessing();
|
|
367
|
+
expect(session.isRunning).toBe(true);
|
|
368
|
+
cleanup();
|
|
369
|
+
expect(session.isRunning).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("undo/checkpointing", () => {
|
|
374
|
+
test("canUndo is false by default", () => {
|
|
375
|
+
expect(session.canUndo).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("undoCheckpoints is 0 by default", () => {
|
|
379
|
+
expect(session.undoCheckpoints).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("undo fails when no session active", async () => {
|
|
383
|
+
const [success, message] = await session.undo();
|
|
384
|
+
expect(success).toBe(false);
|
|
385
|
+
expect(message).toContain("No active session");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("kill resets checkpoints", async () => {
|
|
389
|
+
// Simulate having checkpoints (by accessing private property for testing)
|
|
390
|
+
// @ts-expect-error - accessing private for test
|
|
391
|
+
session._userMessageUuids = ["uuid1", "uuid2"];
|
|
392
|
+
expect(session.undoCheckpoints).toBe(2);
|
|
393
|
+
|
|
394
|
+
await session.kill();
|
|
395
|
+
expect(session.undoCheckpoints).toBe(0);
|
|
396
|
+
expect(session.canUndo).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
});
|
package/src/bot.ts
CHANGED
|
@@ -18,17 +18,24 @@ import {
|
|
|
18
18
|
handleBookmarks,
|
|
19
19
|
handleCallback,
|
|
20
20
|
handleCd,
|
|
21
|
+
handleCompact,
|
|
22
|
+
handleCost,
|
|
21
23
|
handleDocument,
|
|
24
|
+
handleFile,
|
|
25
|
+
handleModel,
|
|
22
26
|
handleNew,
|
|
23
27
|
handlePhoto,
|
|
24
|
-
|
|
28
|
+
handlePlan,
|
|
25
29
|
handleRestart,
|
|
26
30
|
handleResume,
|
|
27
31
|
handleRetry,
|
|
32
|
+
handleSkill,
|
|
28
33
|
handleStart,
|
|
29
34
|
handleStatus,
|
|
30
35
|
handleStop,
|
|
31
36
|
handleText,
|
|
37
|
+
handleThink,
|
|
38
|
+
handleUndo,
|
|
32
39
|
handleVoice,
|
|
33
40
|
} from "./handlers";
|
|
34
41
|
|
|
@@ -61,12 +68,22 @@ bot.use(
|
|
|
61
68
|
bot.command("start", handleStart);
|
|
62
69
|
bot.command("new", handleNew);
|
|
63
70
|
bot.command("stop", handleStop);
|
|
71
|
+
bot.command("c", handleStop);
|
|
72
|
+
bot.command("kill", handleStop);
|
|
73
|
+
bot.command("dc", handleStop);
|
|
64
74
|
bot.command("status", handleStatus);
|
|
65
75
|
bot.command("resume", handleResume);
|
|
66
76
|
bot.command("restart", handleRestart);
|
|
67
77
|
bot.command("retry", handleRetry);
|
|
68
78
|
bot.command("cd", handleCd);
|
|
69
|
-
bot.command("
|
|
79
|
+
bot.command("skill", handleSkill);
|
|
80
|
+
bot.command("file", handleFile);
|
|
81
|
+
bot.command("model", handleModel);
|
|
82
|
+
bot.command("cost", handleCost);
|
|
83
|
+
bot.command("think", handleThink);
|
|
84
|
+
bot.command("plan", handlePlan);
|
|
85
|
+
bot.command("compact", handleCompact);
|
|
86
|
+
bot.command("undo", handleUndo);
|
|
70
87
|
bot.command("bookmarks", handleBookmarks);
|
|
71
88
|
|
|
72
89
|
// ============== Message Handlers ==============
|
|
@@ -113,8 +130,15 @@ await bot.api.setMyCommands([
|
|
|
113
130
|
{ command: "resume", description: "Resume last session" },
|
|
114
131
|
{ command: "stop", description: "Interrupt current query" },
|
|
115
132
|
{ command: "status", description: "Check what Claude is doing" },
|
|
133
|
+
{ command: "model", description: "Switch model (sonnet/opus/haiku)" },
|
|
134
|
+
{ command: "cost", description: "Show token usage and cost" },
|
|
135
|
+
{ command: "think", description: "Force extended thinking" },
|
|
136
|
+
{ command: "plan", description: "Toggle planning mode" },
|
|
137
|
+
{ command: "compact", description: "Trigger context compaction" },
|
|
138
|
+
{ command: "undo", description: "Revert file changes" },
|
|
116
139
|
{ command: "cd", description: "Change working directory" },
|
|
117
|
-
{ command: "
|
|
140
|
+
{ command: "skill", description: "Invoke a Claude Code skill" },
|
|
141
|
+
{ command: "file", description: "Download a file" },
|
|
118
142
|
{ command: "bookmarks", description: "Manage directory bookmarks" },
|
|
119
143
|
{ command: "retry", description: "Retry last message" },
|
|
120
144
|
{ command: "restart", description: "Restart the bot" },
|