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.
@@ -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 (trim old tool results to save space)
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 (from opencode) ───────────────────────────
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
- /** Rough token estimation (fallback) */
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 / 3);
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 / 3);
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 / 3);
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 ?? DEFAULT_MAX_TOKENS;
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 to a short summary.
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
- // Prune tool results in older messages
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 / 3);
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 / 3);
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 ?? DEFAULT_MAX_TOKENS;
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 Prompt ────────────────────────────────────────────────
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
- /** Previous summary stored in the first system message if present */
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 and token-budget tail preservation.
300
+ * Supports incremental summaries, skill protection, configurable model,
301
+ * and overflow replay.
247
302
  */
248
303
  export async function compactMessages(messages, model, config) {
249
- // Step 1: Prune old tool outputs first
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: Generate structured summary using dedicated compaction agent
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
- const shouldContinue = config?.autoContinue !== false;
288
- return { messages: compactedMessages, compacted: true, shouldContinue };
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
- return autoApprove;
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
- /** Ask user for confirmation. Returns true if approved. */
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 (autoApprove)
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
- process.stdout.write(`\n\n\x1b[1;34m⚠ ${message} [y/N] \x1b[0m\n\n`);
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: buildSkillDescription(),
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 to load" },
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) => ` ${f}`).join("\n")}\n</skill_files>` : "",
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
- "## Available Skills",
120
- "Use the `skill` tool to load specialized instructions when a task matches a skill's description.",
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
- "Available skills:",
133
- ...skills.map((s) => `- ${s.name}: ${s.description}`),
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
  }
@@ -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
+ }