min-agent 0.1.8 → 0.2.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/dist/agent.js +6 -421
- package/dist/cli.js +16 -5
- package/dist/compaction.js +91 -29
- package/dist/confirm.js +26 -5
- package/dist/skills.js +29 -19
- package/dist/tui/App.js +15 -0
- package/dist/tui/ConfirmBar.js +13 -0
- package/dist/tui/InputBar.js +83 -0
- package/dist/tui/MessageList.js +33 -0
- package/dist/tui/Spinner.js +6 -0
- package/dist/tui/StatusBar.js +16 -0
- package/dist/tui/index.js +94 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui-chat.js +384 -0
- package/package.json +5 -1
package/dist/compaction.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { generateText } from "ai";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { resolveModel } from "./provider.js";
|
|
2
4
|
/**
|
|
3
5
|
* Context compaction system — modeled after opencode's SessionCompaction.
|
|
4
6
|
*
|
|
@@ -6,11 +8,13 @@ import { generateText } from "ai";
|
|
|
6
8
|
* 1. Real token tracking from API responses
|
|
7
9
|
* 2. Structured summary template (Goal/Progress/Decisions/Files)
|
|
8
10
|
* 3. Incremental summaries (update previous summary instead of rewriting)
|
|
9
|
-
* 4. Tool output pruning
|
|
10
|
-
* 5. Auto-continue after compaction
|
|
11
|
+
* 4. Tool output pruning with skill protection
|
|
12
|
+
* 5. Auto-continue after compaction with overflow replay
|
|
11
13
|
* 6. Token-budget-aware tail preservation
|
|
14
|
+
* 7. Model-aware thresholds (uses actual context window)
|
|
15
|
+
* 8. Configurable compaction model
|
|
12
16
|
*/
|
|
13
|
-
// ─── Structured Summary Template
|
|
17
|
+
// ─── Structured Summary Template ───────────────────────────────────────────
|
|
14
18
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown below. Keep the section order unchanged.
|
|
15
19
|
|
|
16
20
|
## Goal
|
|
@@ -54,8 +58,9 @@ const TAIL_TOKEN_BUDGET_RATIO = 0.25;
|
|
|
54
58
|
const MIN_TAIL_BUDGET = 2000;
|
|
55
59
|
const MAX_TAIL_BUDGET = 8000;
|
|
56
60
|
const PRUNE_PROTECT_TOKENS = 40000;
|
|
57
|
-
const PRUNE_MIN_SAVINGS = 20000;
|
|
58
61
|
const TOOL_OUTPUT_MAX_CHARS = 2000;
|
|
62
|
+
/** Tools whose output should never be pruned during compaction */
|
|
63
|
+
const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
|
|
59
64
|
// ─── Token Tracker ─────────────────────────────────────────────────────────
|
|
60
65
|
export class TokenTracker {
|
|
61
66
|
_lastInputTokens = 0;
|
|
@@ -88,7 +93,7 @@ export class TokenTracker {
|
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
// ─── Token Estimation ──────────────────────────────────────────────────────
|
|
91
|
-
/**
|
|
96
|
+
/** Token estimation: ~4 chars per token (aligned with opencode) */
|
|
92
97
|
export function estimateTokens(messages) {
|
|
93
98
|
let chars = 0;
|
|
94
99
|
for (const msg of messages) {
|
|
@@ -103,70 +108,91 @@ export function estimateTokens(messages) {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
|
-
return Math.ceil(chars /
|
|
111
|
+
return Math.ceil(chars / 4);
|
|
107
112
|
}
|
|
108
113
|
function estimateMessageTokens(msg) {
|
|
109
114
|
if (typeof msg.content === "string")
|
|
110
|
-
return Math.ceil(msg.content.length /
|
|
115
|
+
return Math.ceil(msg.content.length / 4);
|
|
111
116
|
if (Array.isArray(msg.content)) {
|
|
112
117
|
let chars = 0;
|
|
113
118
|
for (const part of msg.content) {
|
|
114
119
|
if ("text" in part && typeof part.text === "string")
|
|
115
120
|
chars += part.text.length;
|
|
116
121
|
}
|
|
117
|
-
return Math.ceil(chars /
|
|
122
|
+
return Math.ceil(chars / 4);
|
|
118
123
|
}
|
|
119
124
|
return 0;
|
|
120
125
|
}
|
|
121
126
|
// ─── Compaction Check ──────────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Check if compaction is needed.
|
|
129
|
+
* Uses model-aware context window from config or getContextWindow cache.
|
|
130
|
+
*/
|
|
122
131
|
export function needsCompaction(messages, tracker, config) {
|
|
123
|
-
const maxTokens = config?.maxTokens ??
|
|
132
|
+
const maxTokens = config?.maxTokens ?? getMaxTokensFromConfig();
|
|
124
133
|
const threshold = maxTokens * COMPACTION_RATIO;
|
|
125
134
|
if (tracker && tracker.lastInputTokens > 0) {
|
|
126
135
|
return tracker.lastInputTokens > threshold;
|
|
127
136
|
}
|
|
128
137
|
return estimateTokens(messages) > threshold;
|
|
129
138
|
}
|
|
139
|
+
/** Get max tokens from user config (model-aware) */
|
|
140
|
+
function getMaxTokensFromConfig() {
|
|
141
|
+
const cfg = loadConfig();
|
|
142
|
+
return cfg.provider?.contextWindow ?? DEFAULT_MAX_TOKENS;
|
|
143
|
+
}
|
|
130
144
|
// ─── Tool Output Pruning ───────────────────────────────────────────────────
|
|
131
145
|
/**
|
|
132
146
|
* Prune old tool outputs in-place to free context space.
|
|
133
|
-
* Keeps recent tool outputs intact, trims older ones
|
|
147
|
+
* Keeps recent tool outputs intact, trims older ones.
|
|
148
|
+
* Protects skill tool outputs from pruning.
|
|
134
149
|
* Returns the estimated tokens saved.
|
|
135
150
|
*/
|
|
136
151
|
export function pruneToolOutputs(messages) {
|
|
137
152
|
let totalTokens = 0;
|
|
138
153
|
let saved = 0;
|
|
139
154
|
let turns = 0;
|
|
140
|
-
// Walk backwards, skip recent 2 turns
|
|
141
155
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
142
156
|
const msg = messages[i];
|
|
143
157
|
if (msg.role === "user")
|
|
144
158
|
turns++;
|
|
145
159
|
if (turns < 2)
|
|
146
160
|
continue;
|
|
147
|
-
//
|
|
161
|
+
// Check if this is a tool result
|
|
148
162
|
if (msg.role === "tool" || (Array.isArray(msg.content) && msg.content.some((p) => p.type === "tool-result"))) {
|
|
163
|
+
// Skip protected tools (skill results are never pruned)
|
|
164
|
+
if (isProtectedToolMessage(msg))
|
|
165
|
+
continue;
|
|
149
166
|
const content = typeof msg.content === "string" ? msg.content : "";
|
|
150
|
-
const estimate = Math.ceil(content.length /
|
|
167
|
+
const estimate = Math.ceil(content.length / 4);
|
|
151
168
|
totalTokens += estimate;
|
|
152
169
|
if (totalTokens > PRUNE_PROTECT_TOKENS && content.length > TOOL_OUTPUT_MAX_CHARS) {
|
|
153
170
|
const truncated = content.slice(0, TOOL_OUTPUT_MAX_CHARS) + "\n\n[... output truncated during compaction ...]";
|
|
154
171
|
msg.content = truncated;
|
|
155
|
-
saved += estimate - Math.ceil(truncated.length /
|
|
172
|
+
saved += estimate - Math.ceil(truncated.length / 4);
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
}
|
|
159
176
|
return saved;
|
|
160
177
|
}
|
|
178
|
+
/** Check if a message is from a protected tool (e.g. skill) */
|
|
179
|
+
function isProtectedToolMessage(msg) {
|
|
180
|
+
if (typeof msg.content === "string") {
|
|
181
|
+
// Skill tool outputs are wrapped in <skill_content> tags
|
|
182
|
+
return msg.content.includes("<skill_content");
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(msg.content)) {
|
|
185
|
+
return msg.content.some((p) => p.type === "tool-result" && PRUNE_PROTECTED_TOOLS.has(p.toolName ?? ""));
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
161
189
|
/**
|
|
162
190
|
* Select how many recent turns to keep verbatim based on token budget.
|
|
163
|
-
* Similar to opencode's select() function.
|
|
164
191
|
*/
|
|
165
192
|
function selectTail(messages, config) {
|
|
166
|
-
const maxTokens = config?.maxTokens ??
|
|
193
|
+
const maxTokens = config?.maxTokens ?? getMaxTokensFromConfig();
|
|
167
194
|
const tailTurns = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
|
|
168
195
|
const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(maxTokens * TAIL_TOKEN_BUDGET_RATIO)));
|
|
169
|
-
// Find user message boundaries (turns)
|
|
170
196
|
const turnStarts = [];
|
|
171
197
|
for (let i = 0; i < messages.length; i++) {
|
|
172
198
|
if (messages[i].role === "user")
|
|
@@ -175,7 +201,6 @@ function selectTail(messages, config) {
|
|
|
175
201
|
if (turnStarts.length <= 1) {
|
|
176
202
|
return { headEnd: 0, tailStart: 0 };
|
|
177
203
|
}
|
|
178
|
-
// Try to keep the last N turns within budget
|
|
179
204
|
let tokensUsed = 0;
|
|
180
205
|
let tailStart = messages.length;
|
|
181
206
|
const recentTurns = turnStarts.slice(-tailTurns);
|
|
@@ -197,7 +222,7 @@ function selectTail(messages, config) {
|
|
|
197
222
|
tailStart = 0;
|
|
198
223
|
return { headEnd: tailStart, tailStart };
|
|
199
224
|
}
|
|
200
|
-
// ─── Compaction Agent
|
|
225
|
+
// ─── Compaction Agent ──────────────────────────────────────────────────────
|
|
201
226
|
const COMPACTION_AGENT_SYSTEM = `You are an anchored context summarization assistant for coding sessions.
|
|
202
227
|
|
|
203
228
|
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
|
|
@@ -207,11 +232,22 @@ If the prompt includes a <previous-summary> block, treat it as the current ancho
|
|
|
207
232
|
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
|
|
208
233
|
|
|
209
234
|
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`;
|
|
210
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the model to use for compaction.
|
|
237
|
+
* If config.compaction.model is set, use that (allows cheap/fast model for summaries).
|
|
238
|
+
* Otherwise falls back to the main model.
|
|
239
|
+
*/
|
|
240
|
+
function resolveCompactionModel(mainModel) {
|
|
241
|
+
const cfg = loadConfig();
|
|
242
|
+
const compactionModelId = cfg.compaction?.model;
|
|
243
|
+
if (compactionModelId) {
|
|
244
|
+
return resolveModel(compactionModelId);
|
|
245
|
+
}
|
|
246
|
+
return mainModel;
|
|
247
|
+
}
|
|
211
248
|
function extractPreviousSummary(messages) {
|
|
212
249
|
const first = messages[0];
|
|
213
250
|
if (first?.role === "system" && typeof first.content === "string" && first.content.includes("[Context Summary")) {
|
|
214
|
-
// Extract just the summary content after the header
|
|
215
251
|
const match = first.content.match(/\[Context Summary[^\]]*\]\n\n([\s\S]*)/);
|
|
216
252
|
return match?.[1];
|
|
217
253
|
}
|
|
@@ -241,12 +277,33 @@ function messageToText(msg) {
|
|
|
241
277
|
}
|
|
242
278
|
return "";
|
|
243
279
|
}
|
|
280
|
+
/** Check if a message contains media (images, etc.) */
|
|
281
|
+
function hasMedia(msg) {
|
|
282
|
+
if (!Array.isArray(msg.content))
|
|
283
|
+
return false;
|
|
284
|
+
return msg.content.some((p) => p.type === "image" || p.type === "file");
|
|
285
|
+
}
|
|
286
|
+
/** Extract text-only content from a message (strip media) */
|
|
287
|
+
function extractTextOnly(msg) {
|
|
288
|
+
if (typeof msg.content === "string")
|
|
289
|
+
return msg.content;
|
|
290
|
+
if (Array.isArray(msg.content)) {
|
|
291
|
+
return msg.content
|
|
292
|
+
.filter((p) => p.type === "text")
|
|
293
|
+
.map((p) => p.text)
|
|
294
|
+
.join("\n");
|
|
295
|
+
}
|
|
296
|
+
return "";
|
|
297
|
+
}
|
|
244
298
|
/**
|
|
245
299
|
* Compact messages by summarizing older history with structured template.
|
|
246
|
-
* Supports incremental summaries
|
|
300
|
+
* Supports incremental summaries, skill protection, configurable model,
|
|
301
|
+
* and overflow replay.
|
|
247
302
|
*/
|
|
248
303
|
export async function compactMessages(messages, model, config) {
|
|
249
|
-
|
|
304
|
+
const cfg = loadConfig();
|
|
305
|
+
const autoContinue = config?.autoContinue ?? cfg.compaction?.autoContinue ?? true;
|
|
306
|
+
// Step 1: Prune old tool outputs (skip skill results)
|
|
250
307
|
pruneToolOutputs(messages);
|
|
251
308
|
// Step 2: Select tail (recent turns to keep verbatim)
|
|
252
309
|
const { headEnd, tailStart } = selectTail(messages, config);
|
|
@@ -262,15 +319,16 @@ export async function compactMessages(messages, model, config) {
|
|
|
262
319
|
.map((msg) => {
|
|
263
320
|
const role = msg.role;
|
|
264
321
|
const text = messageToText(msg);
|
|
265
|
-
// Limit each message to avoid overwhelming the summarizer
|
|
266
322
|
return `[${role}]: ${text.slice(0, 3000)}`;
|
|
267
323
|
})
|
|
268
324
|
.join("\n\n");
|
|
269
|
-
// Step 5:
|
|
325
|
+
// Step 5: Resolve compaction model (may differ from main model)
|
|
326
|
+
const compactionModel = resolveCompactionModel(model);
|
|
327
|
+
// Step 6: Generate structured summary
|
|
270
328
|
try {
|
|
271
329
|
const prompt = buildCompactionPrompt(previousSummary);
|
|
272
330
|
const result = await generateText({
|
|
273
|
-
model,
|
|
331
|
+
model: compactionModel,
|
|
274
332
|
system: COMPACTION_AGENT_SYSTEM,
|
|
275
333
|
messages: [
|
|
276
334
|
{ role: "user", content: conversationText + "\n\n" + prompt },
|
|
@@ -284,11 +342,15 @@ export async function compactMessages(messages, model, config) {
|
|
|
284
342
|
},
|
|
285
343
|
...toKeep,
|
|
286
344
|
];
|
|
287
|
-
|
|
288
|
-
|
|
345
|
+
// Step 7: Check if the last user message had media — if so, provide replay text
|
|
346
|
+
let replayText;
|
|
347
|
+
const lastUserMsg = toKeep.find((m) => m.role === "user");
|
|
348
|
+
if (lastUserMsg && hasMedia(lastUserMsg)) {
|
|
349
|
+
replayText = extractTextOnly(lastUserMsg);
|
|
350
|
+
}
|
|
351
|
+
return { messages: compactedMessages, compacted: true, shouldContinue: autoContinue, replayText };
|
|
289
352
|
}
|
|
290
353
|
catch {
|
|
291
|
-
// Fallback: just keep the tail
|
|
292
354
|
return { messages: toKeep, compacted: true, shouldContinue: false };
|
|
293
355
|
}
|
|
294
356
|
}
|
package/dist/confirm.js
CHANGED
|
@@ -1,33 +1,54 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
1
2
|
let autoApprove = false;
|
|
2
3
|
export function setAutoApprove(value) {
|
|
3
4
|
autoApprove = value;
|
|
4
5
|
}
|
|
5
6
|
export function isAutoApprove() {
|
|
6
|
-
|
|
7
|
+
if (autoApprove)
|
|
8
|
+
return true;
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
return config.permission === "allow-all";
|
|
7
11
|
}
|
|
8
12
|
/** Optional readline interface to pause/resume during confirmation prompts. */
|
|
9
13
|
let _rl = null;
|
|
10
14
|
export function setConfirmReadline(rl) {
|
|
11
15
|
_rl = rl;
|
|
12
16
|
}
|
|
13
|
-
/**
|
|
17
|
+
/** Optional TUI confirm handler — when set, confirm() delegates to the TUI overlay. */
|
|
18
|
+
let _tuiConfirm = null;
|
|
19
|
+
export function setTuiConfirm(handler) {
|
|
20
|
+
_tuiConfirm = handler;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ask user for confirmation. Returns true if approved.
|
|
24
|
+
* Display order: detail first, then [y/N] prompt at the bottom.
|
|
25
|
+
*/
|
|
14
26
|
export async function confirm(message) {
|
|
15
|
-
if (
|
|
27
|
+
if (isAutoApprove())
|
|
16
28
|
return true;
|
|
29
|
+
// If TUI mode is active, delegate to the TUI overlay
|
|
30
|
+
if (_tuiConfirm)
|
|
31
|
+
return _tuiConfirm(message);
|
|
17
32
|
// Pause readline so it doesn't consume/echo the keystroke
|
|
18
33
|
_rl?.pause();
|
|
19
|
-
|
|
34
|
+
// Detail on top, prompt at the bottom
|
|
35
|
+
process.stdout.write(`\n\x1b[90m┌─ 即将执行 ─────────────────────────────────\x1b[0m\n`);
|
|
36
|
+
process.stdout.write(`\x1b[90m│\x1b[0m ${message}\n`);
|
|
37
|
+
process.stdout.write(`\x1b[90m└────────────────────────────────────────────\x1b[0m\n`);
|
|
38
|
+
process.stdout.write(`\n\x1b[1;34m? 是否允许执行? [y/N] \x1b[0m`);
|
|
20
39
|
return new Promise((resolve) => {
|
|
21
40
|
const wasRaw = process.stdin.isRaw;
|
|
22
41
|
if (process.stdin.isTTY)
|
|
23
42
|
process.stdin.setRawMode(true);
|
|
43
|
+
// Ensure stdin is flowing so we can receive data even after readline pause
|
|
44
|
+
process.stdin.resume();
|
|
24
45
|
const onData = (buf) => {
|
|
25
46
|
const ch = buf.toString();
|
|
26
47
|
process.stdin.removeListener("data", onData);
|
|
27
48
|
if (process.stdin.isTTY)
|
|
28
49
|
process.stdin.setRawMode(wasRaw ?? false);
|
|
29
50
|
// Echo the character and newline
|
|
30
|
-
process.stdout.write(ch === "\r" || ch === "\n" ? "\n" : `${ch}\n`);
|
|
51
|
+
process.stdout.write(ch === "\r" || ch === "\n" ? "\n\n" : `${ch}\n\n`);
|
|
31
52
|
// Resume readline after confirmation
|
|
32
53
|
_rl?.resume();
|
|
33
54
|
const answer = ch.trim().toLowerCase();
|
package/dist/skills.js
CHANGED
|
@@ -25,6 +25,9 @@ export function discoverSkills(opts) {
|
|
|
25
25
|
for (const match of matches) {
|
|
26
26
|
const skill = parseSkillFile(match);
|
|
27
27
|
if (skill) {
|
|
28
|
+
if (loadedSkills[skill.name]) {
|
|
29
|
+
console.warn(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${match} overrides ${loadedSkills[skill.name].location}\x1b[0m`);
|
|
30
|
+
}
|
|
28
31
|
loadedSkills[skill.name] = skill;
|
|
29
32
|
}
|
|
30
33
|
}
|
|
@@ -39,7 +42,6 @@ export function discoverSkills(opts) {
|
|
|
39
42
|
function parseSkillFile(filePath) {
|
|
40
43
|
try {
|
|
41
44
|
const raw = readFileSync(filePath, "utf-8");
|
|
42
|
-
// Parse frontmatter (---\n...\n---)
|
|
43
45
|
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
44
46
|
if (!fmMatch)
|
|
45
47
|
return null;
|
|
@@ -67,13 +69,19 @@ export function getSkills() {
|
|
|
67
69
|
export function getSkill(name) {
|
|
68
70
|
return loadedSkills[name];
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Skill tool — content is loaded on-demand when the model calls this tool.
|
|
74
|
+
* The tool description is kept minimal; the full skill list lives in the system prompt.
|
|
75
|
+
*/
|
|
70
76
|
export function getSkillsTool() {
|
|
71
77
|
return tool({
|
|
72
|
-
description:
|
|
78
|
+
description: "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt. " +
|
|
79
|
+
"Use this tool to inject the skill's instructions and resources into the current conversation. " +
|
|
80
|
+
"The skill name must match one of the skills listed in your system prompt under available_skills.",
|
|
73
81
|
inputSchema: jsonSchema({
|
|
74
82
|
type: "object",
|
|
75
83
|
properties: {
|
|
76
|
-
name: { type: "string", description: "The name of the skill
|
|
84
|
+
name: { type: "string", description: "The name of the skill from available_skills" },
|
|
77
85
|
},
|
|
78
86
|
required: ["name"],
|
|
79
87
|
}),
|
|
@@ -101,9 +109,10 @@ export function getSkillsTool() {
|
|
|
101
109
|
"",
|
|
102
110
|
skill.content,
|
|
103
111
|
"",
|
|
104
|
-
`Base directory: ${dir}`,
|
|
112
|
+
`Base directory for this skill: ${dir}`,
|
|
113
|
+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
105
114
|
"",
|
|
106
|
-
files.length ? `<skill_files>\n${files.map((f) => `
|
|
115
|
+
files.length ? `<skill_files>\n${files.map((f) => ` <file>${path.join(dir, f)}</file>`).join("\n")}\n</skill_files>` : "",
|
|
107
116
|
`</skill_content>`,
|
|
108
117
|
]
|
|
109
118
|
.filter(Boolean)
|
|
@@ -111,25 +120,26 @@ export function getSkillsTool() {
|
|
|
111
120
|
},
|
|
112
121
|
});
|
|
113
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* System prompt section — verbose XML format for better model comprehension.
|
|
125
|
+
* Only name/description/location are injected; content is loaded on-demand via the skill tool.
|
|
126
|
+
*/
|
|
114
127
|
export function getSkillsSystemPrompt() {
|
|
115
128
|
const skills = getSkills();
|
|
116
129
|
if (skills.length === 0)
|
|
117
130
|
return "";
|
|
118
131
|
return [
|
|
119
|
-
"
|
|
120
|
-
"Use the
|
|
121
|
-
"",
|
|
122
|
-
...skills.map((s) => `- **${s.name}**: ${s.description}`),
|
|
123
|
-
].join("\n");
|
|
124
|
-
}
|
|
125
|
-
function buildSkillDescription() {
|
|
126
|
-
const skills = getSkills();
|
|
127
|
-
if (skills.length === 0)
|
|
128
|
-
return "Load a specialized skill. No skills are currently available.";
|
|
129
|
-
return [
|
|
130
|
-
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
132
|
+
"Skills provide specialized instructions and workflows for specific tasks.",
|
|
133
|
+
"Use the skill tool to load a skill when a task matches its description.",
|
|
131
134
|
"",
|
|
132
|
-
"
|
|
133
|
-
...skills.
|
|
135
|
+
"<available_skills>",
|
|
136
|
+
...skills.flatMap((s) => [
|
|
137
|
+
" <skill>",
|
|
138
|
+
` <name>${s.name}</name>`,
|
|
139
|
+
` <description>${s.description}</description>`,
|
|
140
|
+
` <location>${s.location}</location>`,
|
|
141
|
+
" </skill>",
|
|
142
|
+
]),
|
|
143
|
+
"</available_skills>",
|
|
134
144
|
].join("\n");
|
|
135
145
|
}
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, useInput } from "ink";
|
|
3
|
+
import { Spinner } from "./Spinner.js";
|
|
4
|
+
import { InputBar } from "./InputBar.js";
|
|
5
|
+
import { MessageList } from "./MessageList.js";
|
|
6
|
+
import { ConfirmBar } from "./ConfirmBar.js";
|
|
7
|
+
import { StatusBar } from "./StatusBar.js";
|
|
8
|
+
export function App({ initialState, onSubmit, onConfirm, onExit }) {
|
|
9
|
+
useInput((_input, key) => {
|
|
10
|
+
if (key.escape && initialState.isRunning) {
|
|
11
|
+
onExit();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { messages: initialState.messages }), initialState.isRunning && (_jsx(Box, { paddingLeft: 1, children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning, placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)" }))] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
export function ConfirmBar({ message, onConfirm }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
if (input.toLowerCase() === "y") {
|
|
6
|
+
onConfirm(true);
|
|
7
|
+
}
|
|
8
|
+
else if (input.toLowerCase() === "n" || key.escape || key.return) {
|
|
9
|
+
onConfirm(false);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "blue", paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u250C\u2500 \u5373\u5C06\u6267\u884C \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message }) }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "? \u662F\u5426\u5141\u8BB8\u6267\u884C\uFF1F " }), _jsx(Text, { color: "green", children: "[y]" }), _jsx(Text, { color: "gray", children: " / " }), _jsx(Text, { color: "red", children: "[N]" })] })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdin } from "ink";
|
|
4
|
+
/**
|
|
5
|
+
* Multi-line input bar.
|
|
6
|
+
* - Enter: submit
|
|
7
|
+
* - Shift+Enter (Kitty protocol terminals): new line
|
|
8
|
+
* - Ctrl+J: new line (universal fallback)
|
|
9
|
+
* - Backslash at end + Enter: continue on next line
|
|
10
|
+
*/
|
|
11
|
+
export function InputBar({ onSubmit, disabled, placeholder }) {
|
|
12
|
+
const [value, setValue] = useState("");
|
|
13
|
+
const [shiftEnterDetected, setShiftEnterDetected] = useState(false);
|
|
14
|
+
const { stdin } = useStdin();
|
|
15
|
+
// Listen for Kitty protocol Shift+Enter: ESC[13;2u
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!stdin || disabled)
|
|
18
|
+
return;
|
|
19
|
+
const handleData = (data) => {
|
|
20
|
+
const str = data.toString("utf-8");
|
|
21
|
+
// Kitty keyboard protocol: \x1b[13;2u = Shift+Enter
|
|
22
|
+
if (str.includes("\x1b[13;2u")) {
|
|
23
|
+
setShiftEnterDetected(true);
|
|
24
|
+
setValue((v) => v + "\n");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
stdin.on("data", handleData);
|
|
28
|
+
return () => { stdin.off("data", handleData); };
|
|
29
|
+
}, [stdin, disabled]);
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
if (disabled)
|
|
32
|
+
return;
|
|
33
|
+
// If we just handled a Shift+Enter via raw stdin, skip this cycle
|
|
34
|
+
if (shiftEnterDetected) {
|
|
35
|
+
setShiftEnterDetected(false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// Ctrl+J: insert newline
|
|
39
|
+
if (key.ctrl && input === "j") {
|
|
40
|
+
setValue((v) => v + "\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Enter: submit or continue if ends with backslash
|
|
44
|
+
if (key.return) {
|
|
45
|
+
if (value.endsWith("\\")) {
|
|
46
|
+
setValue((v) => v.slice(0, -1) + "\n");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const text = value.trim();
|
|
50
|
+
if (text) {
|
|
51
|
+
onSubmit(text);
|
|
52
|
+
setValue("");
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Backspace / Delete
|
|
57
|
+
if (key.backspace || key.delete) {
|
|
58
|
+
setValue((v) => v.slice(0, -1));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Escape, arrows, etc. — ignore (handled elsewhere)
|
|
62
|
+
if (key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
|
|
63
|
+
return;
|
|
64
|
+
if (key.pageUp || key.pageDown)
|
|
65
|
+
return;
|
|
66
|
+
if (key.ctrl || key.meta)
|
|
67
|
+
return;
|
|
68
|
+
// Tab → 2 spaces
|
|
69
|
+
if (key.tab) {
|
|
70
|
+
setValue((v) => v + " ");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Normal character input (including CJK)
|
|
74
|
+
if (input) {
|
|
75
|
+
setValue((v) => v + input);
|
|
76
|
+
}
|
|
77
|
+
}, { isActive: !disabled });
|
|
78
|
+
const isEmpty = value === "";
|
|
79
|
+
const lines = value.split("\n");
|
|
80
|
+
const isMultiline = lines.length > 1;
|
|
81
|
+
const borderColor = disabled ? "gray" : "cyan";
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: disabled ? "gray" : "cyan", children: "\u276F " }), isEmpty && placeholder ? (_jsx(Text, { color: "gray", dimColor: true, children: placeholder })) : (_jsxs(Text, { wrap: "wrap", children: [value, "\u2588"] }))] }), isMultiline && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { color: "gray", dimColor: true, children: [lines.length, " \u884C | Shift+Enter/Ctrl+J \u6362\u884C | Enter \u53D1\u9001"] }) }))] }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { MarkdownRenderer } from "../markdown.js";
|
|
4
|
+
/**
|
|
5
|
+
* Message list — renders all messages naturally.
|
|
6
|
+
* Scrolling is handled by the terminal's own scrollback buffer.
|
|
7
|
+
*/
|
|
8
|
+
export function MessageList({ messages }) {
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", children: [messages.length === 0 && (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { color: "gray", children: "\u8F93\u5165\u6D88\u606F\u5F00\u59CB\u5BF9\u8BDD\uFF0C\u8F93\u5165 /help \u67E5\u770B\u547D\u4EE4\uFF0CEsc \u53D6\u6D88\u8FD0\u884C" }) })), messages.map((msg) => (_jsx(MessageRow, { message: msg }, msg.id)))] }));
|
|
10
|
+
}
|
|
11
|
+
/** Render markdown content to ANSI-formatted string */
|
|
12
|
+
function renderMarkdown(content) {
|
|
13
|
+
const md = new MarkdownRenderer();
|
|
14
|
+
const output = md.write(content);
|
|
15
|
+
const flushed = md.flush();
|
|
16
|
+
return output + flushed;
|
|
17
|
+
}
|
|
18
|
+
function MessageRow({ message }) {
|
|
19
|
+
switch (message.role) {
|
|
20
|
+
case "user":
|
|
21
|
+
return (_jsxs(Box, { paddingLeft: 1, marginTop: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [">", " "] }), _jsx(Text, { children: message.content })] }));
|
|
22
|
+
case "assistant":
|
|
23
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: renderMarkdown(message.content) }) }));
|
|
24
|
+
case "thinking":
|
|
25
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: message.content }) }));
|
|
26
|
+
case "tool":
|
|
27
|
+
return (_jsxs(Box, { paddingLeft: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A1 ", message.toolName, " "] }), _jsx(Text, { color: "gray", children: message.content.length > 120 ? message.content.slice(0, 120) + "..." : message.content })] }));
|
|
28
|
+
case "system":
|
|
29
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: message.content }) }));
|
|
30
|
+
default:
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import InkSpinner from "ink-spinner";
|
|
4
|
+
export function Spinner({ label }) {
|
|
5
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(InkSpinner, { type: "dots" }) }), label && _jsxs(Text, { color: "gray", children: [" ", label] })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function StatusBar({ state }) {
|
|
4
|
+
const model = state.model || "unknown";
|
|
5
|
+
const tokens = state.tokenInfo;
|
|
6
|
+
let bar = "";
|
|
7
|
+
let barColor = "green";
|
|
8
|
+
if (tokens && tokens.contextWindow > 0) {
|
|
9
|
+
const pct = Math.round((tokens.input / tokens.contextWindow) * 100);
|
|
10
|
+
const filled = Math.round(pct / 10);
|
|
11
|
+
const empty = 10 - filled;
|
|
12
|
+
bar = `${tokens.input}/${tokens.contextWindow} ${"█".repeat(filled)}${"░".repeat(empty)} ${pct}%`;
|
|
13
|
+
barColor = pct >= 80 ? "red" : pct >= 50 ? "yellow" : "green";
|
|
14
|
+
}
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "─".repeat(60) }), _jsxs(Box, { children: [_jsx(Text, { children: " \uD83E\uDD16 min-agent" }), _jsxs(Text, { color: "gray", children: [" (", model, ")"] }), bar && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: barColor, children: bar })] }))] })] }));
|
|
16
|
+
}
|