mini-coder 0.5.14 → 0.6.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.
Files changed (70) hide show
  1. package/README.md +25 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +237 -1403
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -358
  8. package/src/index.ts +29 -1016
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -284
  11. package/src/session.ts +55 -1306
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -5
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/assistant-output.ts +0 -73
  39. package/src/cli.ts +0 -134
  40. package/src/delegation.ts +0 -238
  41. package/src/errors.ts +0 -15
  42. package/src/git.ts +0 -247
  43. package/src/input.ts +0 -168
  44. package/src/mcp.ts +0 -609
  45. package/src/paths.ts +0 -37
  46. package/src/session-message.ts +0 -385
  47. package/src/settings.ts +0 -449
  48. package/src/skills.ts +0 -271
  49. package/src/submit.ts +0 -376
  50. package/src/text.ts +0 -71
  51. package/src/theme.ts +0 -330
  52. package/src/tool-common.ts +0 -93
  53. package/src/tool-delegate.ts +0 -125
  54. package/src/tool-grep.ts +0 -606
  55. package/src/tool-read.ts +0 -313
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/shared.ts CHANGED
@@ -1,39 +1,118 @@
1
- /**
2
- * Shared runtime record/primitive readers used across the app.
3
- *
4
- * @module
5
- */
6
-
7
- /** Convert an unknown value into a plain record, rejecting arrays and null. */
8
- export function toRecord(value: unknown): Record<string, unknown> | null {
9
- return typeof value === "object" && value !== null && !Array.isArray(value)
10
- ? (value as Record<string, unknown>)
11
- : null;
12
- }
13
-
14
- /** Read a string field from a record, returning null for missing or invalid values. */
15
- export function readString(
16
- record: Record<string, unknown>,
17
- key: string,
18
- ): string | null {
19
- const value = record[key];
20
- return typeof value === "string" ? value : null;
21
- }
22
-
23
- /** Read a boolean field from a record, returning null for missing or invalid values. */
24
- export function readBoolean(
25
- record: Record<string, unknown>,
26
- key: string,
27
- ): boolean | null {
28
- const value = record[key];
29
- return typeof value === "boolean" ? value : null;
30
- }
31
-
32
- /** Read a finite numeric field from a record, returning null for missing or invalid values. */
33
- export function readFiniteNumber(
34
- record: Record<string, unknown>,
35
- key: string,
36
- ): number | null {
37
- const value = record[key];
38
- return typeof value === "number" && Number.isFinite(value) ? value : null;
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { parseDocument } from "yaml";
4
+
5
+ // Mixed bag of helpers that can be shared across the codebase
6
+
7
+ export const DATA_DIR = join(homedir(), ".config", "mini-coder");
8
+ export const SESSIONS_DIR = join(DATA_DIR, "sessions");
9
+ export const AUTH_PATH = join(DATA_DIR, "auth.json");
10
+ export const SETTINGS_PATH = join(DATA_DIR, "settings.json");
11
+
12
+ export function secureRandomString(
13
+ length: number,
14
+ chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
15
+ ): string {
16
+ const result: string[] = [];
17
+ const charsLength = chars.length;
18
+ const maxValid = Math.floor(256 / charsLength) * charsLength;
19
+ const randomBytes = new Uint8Array(length * 2);
20
+
21
+ while (result.length < length) {
22
+ crypto.getRandomValues(randomBytes);
23
+
24
+ for (const byte of randomBytes) {
25
+ if (byte < maxValid) {
26
+ result.push(chars[byte % charsLength]);
27
+ if (result.length === length) break;
28
+ }
29
+ }
30
+ }
31
+
32
+ return result.join("");
33
+ }
34
+
35
+ export function elapsedTime(seconds: number): string {
36
+ if (seconds < 60) return `${seconds}s`;
37
+
38
+ const minutes = Math.floor(seconds / 60);
39
+ if (minutes < 60) return `${minutes}m`;
40
+
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 24) return `${hours}h`;
43
+
44
+ const days = Math.floor(hours / 24);
45
+ if (days < 7) return `${days}d`;
46
+
47
+ const weeks = Math.floor(days / 7);
48
+ if (weeks < 4) return `${weeks}w`;
49
+
50
+ const months = Math.floor(days / 30);
51
+ if (months < 12) return `${months}mo`;
52
+
53
+ const years = Math.floor(days / 365);
54
+ return `${years}y`;
55
+ }
56
+
57
+ export function relativeTime(timestamp: number): string {
58
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
59
+ return elapsedTime(seconds);
60
+ }
61
+
62
+ export function onceEvery<T extends unknown[]>(
63
+ n: number,
64
+ fn: (...args: T) => void,
65
+ ) {
66
+ let calls = 0;
67
+
68
+ return (...args: T) => {
69
+ calls++;
70
+
71
+ if (calls % n === 0) {
72
+ fn(...args);
73
+ }
74
+ };
75
+ }
76
+
77
+ export function takeTail<T>(arr: T[], x: number): T[] {
78
+ return x <= 0 ? [] : arr.slice(-x);
79
+ }
80
+
81
+ export function estimateTokens(text: string): number {
82
+ return Math.ceil(text.length / 4);
83
+ }
84
+
85
+ export function parseSkillFrontmatter(content: string) {
86
+ const match = /^---\s*\n([\s\S]*?)\n---/.exec(content);
87
+
88
+ if (!match) {
89
+ return undefined;
90
+ }
91
+
92
+ const doc = parseDocument(match[1]);
93
+ const data = doc.toJS() as unknown;
94
+
95
+ if (!data || typeof data !== "object") {
96
+ return undefined;
97
+ }
98
+
99
+ const record = data as Record<string, unknown>;
100
+ const name = typeof record.name === "string" ? record.name.trim() : "";
101
+ const description =
102
+ typeof record.description === "string" ? record.description.trim() : "";
103
+
104
+ if (!name || !description) {
105
+ return undefined;
106
+ }
107
+
108
+ return { name, description };
109
+ }
110
+
111
+ export function formatTimestamp(timestampMs: number): string {
112
+ return new Date(timestampMs).toLocaleTimeString("en-GB", {
113
+ hour: "2-digit",
114
+ minute: "2-digit",
115
+ second: "2-digit",
116
+ hour12: false,
117
+ });
39
118
  }
@@ -0,0 +1,110 @@
1
+ import { type Tool, Type } from "@mariozechner/pi-ai";
2
+ import { secureRandomString } from "./shared";
3
+ import type { ToolRunnerEvent } from "./types";
4
+
5
+ const OUTPUT_THRESHOLD = 16000;
6
+ const description = `## Bash CLI tool
7
+
8
+ Execute shell commands on the user's environment.
9
+
10
+ Best practices:
11
+
12
+ - Use \`ls\` to list files.
13
+ - Use \`fd\` or \`find\` to locate files/directories by name, type, size, time, permissions, etc.
14
+ - Use \`rg\` or \`grep\` to search inside files for matching patterns.
15
+ - Use \`cat -n\` or \`nl\` to read small files, or when you need the whole file.
16
+ - Use \`sed -n\` with ranges to read sections of files. Prefer targeted reads. Avoid dumping very large (more than ~200 lines) files all at once.
17
+ - Use \`cp\`, \`mv\`, and \`mkdir\` for file and directory operations, and \`rm\` to remove files and directories.
18
+ - Use \`curl\` for web access. Use redirection to temp files for targeted reads.
19
+ - Use development tools like \`git\`, \`gh\`, \`jq\`, etc, when appropriate.
20
+ - Prefer \`cp -i\`, \`mv -i\`, \`rm -i\` when learning.
21
+ - NEVER run destructive commands (\`rm -rf\`, \`git reset --hard\`, overwriting files) without confirming the target first.
22
+ - Chain commands **only** when failure should stop the flow. Avoid long chains, **2 to 3 maximum**.
23
+ - Avoid overly complex one-liners; readability matters.
24
+ - Quote filenames: use \`"$file"\` not \`$file\`.
25
+ - Be careful with spaces in filenames.
26
+
27
+ Commands run in: ${process.cwd()}
28
+ `;
29
+
30
+ export const bash: Tool = {
31
+ name: "bash",
32
+ description,
33
+ parameters: Type.Object({
34
+ command: Type.String({
35
+ description:
36
+ "Shell command to execute. Prefer simple, focused commands over complex one-liners.",
37
+ }),
38
+ }),
39
+ };
40
+
41
+ export async function* runBashTool(
42
+ args: Record<string, any>,
43
+ signal?: AbortSignal,
44
+ ): AsyncGenerator<ToolRunnerEvent> {
45
+ // Redirect stderr into stdout for the whole shell session.
46
+ const proc = Bun.spawn(["bash", "-c", `exec 2>&1; ${args.command}`], {
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ env: {
50
+ ...Bun.env,
51
+ NO_COLOR: "1",
52
+ },
53
+ signal,
54
+ });
55
+
56
+ const decoder = new TextDecoder();
57
+ const reader = proc.stdout.getReader();
58
+
59
+ let output = "";
60
+ while (true) {
61
+ const { done, value } = await reader.read();
62
+
63
+ if (done) {
64
+ const remaining = Bun.stripANSI(decoder.decode());
65
+
66
+ if (remaining.length) {
67
+ output += remaining;
68
+ yield { type: "output", text: remaining };
69
+ }
70
+ break;
71
+ }
72
+
73
+ const text = Bun.stripANSI(decoder.decode(value, { stream: true }));
74
+
75
+ if (text.length) {
76
+ output += text;
77
+ yield { type: "output", text: text };
78
+ }
79
+ }
80
+
81
+ const exitCode = await proc.exited;
82
+
83
+ let result = `# EXIT CODE: ${exitCode}`;
84
+ if (output.length) {
85
+ result += `
86
+ # OUTPUT:
87
+
88
+ ${output}`;
89
+ }
90
+
91
+ // If `out` is too big, more than ~XXKB, write it to a temp file
92
+ // And add that to the truncation label for the agent to be able
93
+ // to continue the read with scans. This is to protect context,
94
+ // not a general read guard. The hint is for the agent, not the TUI
95
+ if (result.length > OUTPUT_THRESHOLD) {
96
+ const key = `${Date.now()}-${secureRandomString(4)}`;
97
+ const pathname = `/tmp/bash_result_${key}.txt`;
98
+ await Bun.write(pathname, result);
99
+ result = `${result.substring(0, OUTPUT_THRESHOLD)}
100
+
101
+ Truncated at ~${OUTPUT_THRESHOLD / 1000}KB. Full output at ${pathname}`;
102
+ }
103
+
104
+ yield {
105
+ type: "result",
106
+ text: result,
107
+ };
108
+
109
+ return result;
110
+ }
@@ -0,0 +1,133 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import { type Tool, Type } from "@mariozechner/pi-ai";
3
+ import { createPatch } from "diff";
4
+ import type { ToolRunnerEvent } from "./types";
5
+
6
+ const description = `## Edit tool
7
+
8
+ A find-and-replace file editor. Use it to create new files or modify existing ones safely. Always prefer this tool over bash editing methods (sed, awk, etc).
9
+
10
+ ### Rules
11
+ - The tool refuses to edit on multiple matches of \`oldText\`. Be specific with your matching text.
12
+ - Prefer patch-based edits (small targeted replacements) for multi-line or semantic changes.
13
+ - Do NOT reproduce entire files. Use shell file operations (\`cp\`, \`mv\`, etc) for wholesale file replacement instead.
14
+
15
+ ### Failure modes
16
+ - If \`oldText\` is not found, the edit fails. Verify the exact text first.
17
+ - If \`oldText\` matches multiple locations, the edit fails. Narrow your match and retry.
18
+ - If the file does not exist and \`oldText\` is non-empty, the edit fails.
19
+
20
+ <example>
21
+ Edit a single line:
22
+ path: src/utils.ts
23
+ oldText: const MAX_RETRIES = 3;
24
+ newText: const MAX_RETRIES = 5;
25
+ </example>
26
+
27
+ <example>
28
+ Patch-based edit (preferred for multi-line changes):
29
+ path: src/utils.ts
30
+ oldText: function oldHelper() {\n return 1;\n}
31
+ newText: function newHelper() {\n return 2;\n}\n\nfunction oldHelper() {\n return 1;\n}
32
+ </example>
33
+ `;
34
+
35
+ export const edit: Tool = {
36
+ name: `edit`,
37
+ description,
38
+ parameters: Type.Object({
39
+ path: Type.String({
40
+ description:
41
+ "File path. Absolute or relative to the current working directory.",
42
+ }),
43
+ oldText: Type.String({
44
+ description:
45
+ 'Exact text to find and replace. Empty string means "create new file".',
46
+ }),
47
+ newText: Type.String({
48
+ description: "Replacement text (or full content for new files)",
49
+ }),
50
+ }),
51
+ };
52
+
53
+ function findAllIndexes(text: string, sub: string): number[] {
54
+ if (sub.length === 0) return [];
55
+
56
+ const indexes: number[] = [];
57
+
58
+ let pos = text.indexOf(sub, 0);
59
+ while (pos !== -1) {
60
+ indexes.push(pos);
61
+ pos += sub.length; // use pos += 1 if you want overlapping matches
62
+ pos = text.indexOf(sub, pos);
63
+ }
64
+
65
+ return indexes;
66
+ }
67
+
68
+ export async function* runEditTool(
69
+ args: Record<string, any>,
70
+ signal?: AbortSignal,
71
+ ): AsyncGenerator<ToolRunnerEvent> {
72
+ const filePath = isAbsolute(args.path)
73
+ ? args.path
74
+ : join(process.cwd(), args.path);
75
+ const file = Bun.file(filePath);
76
+ const exists = await file.exists();
77
+
78
+ if (args.oldText === "") {
79
+ if (exists) {
80
+ yield { type: "result", text: `File already exists: ${filePath}` };
81
+ return;
82
+ }
83
+
84
+ await Bun.write(file, args.newText);
85
+ yield {
86
+ type: "result",
87
+ text: `File written: ${filePath}\n\n${args.newText}`,
88
+ };
89
+ return;
90
+ }
91
+
92
+ if (!exists) {
93
+ yield { type: "result", text: `File not found: ${filePath}` };
94
+ return;
95
+ }
96
+
97
+ const content = await file.text();
98
+ const matches = findAllIndexes(content, args.oldText);
99
+
100
+ if (matches.length === 0) {
101
+ yield { type: "result", text: `Old text not found in: ${filePath}` };
102
+ return;
103
+ }
104
+
105
+ if (matches.length > 1) {
106
+ yield {
107
+ type: "result",
108
+ text: `Multiple matches found in ${filePath}: ${matches.length} matches, be more specific and try again`,
109
+ };
110
+ return;
111
+ }
112
+
113
+ const idx = matches[0];
114
+ const updated =
115
+ content.slice(0, idx) +
116
+ args.newText +
117
+ content.slice(idx + args.oldText.length);
118
+
119
+ if (signal?.aborted) {
120
+ yield { type: "result", text: "Aborted before write." };
121
+ return;
122
+ }
123
+
124
+ await file.write(updated);
125
+ const patch = createPatch(filePath, content, updated);
126
+
127
+ yield {
128
+ type: "result",
129
+ text: `File edited: ${filePath}\n\n${patch}`,
130
+ };
131
+
132
+ return;
133
+ }
@@ -0,0 +1,114 @@
1
+ import { type Message, type Tool, Type } from "@mariozechner/pi-ai";
2
+ import { streamAgent } from "./agent";
3
+ import { buildSystemPrompt, TASK_PROMPT } from "./prompt";
4
+ import { estimateTokens, formatTimestamp } from "./shared";
5
+ import { bash, runBashTool } from "./tool-bash";
6
+ import { edit, runEditTool } from "./tool-edit";
7
+ import type {
8
+ AgentContex,
9
+ CliOptions,
10
+ ToolAndRunner,
11
+ ToolRunnerEvent,
12
+ } from "./types";
13
+
14
+ const description = `## Task tool
15
+
16
+ Use this tool for multi-step work that would otherwise require multiple individual tool calls. The tool executes a detailed plan and returns results.
17
+
18
+ Best practices:
19
+
20
+ - For exploration (codebase, filesystem, web), specify exactly what you are looking for and the expected output format. For example: "Find all files relevant to tests. Return the exact paths and a description of each file."
21
+ - For edits, specify exact diffs and the target file for each change.
22
+ - For anything else, provide a specific, detailed set of instructions and your expected results.
23
+ `;
24
+
25
+ export const task: Tool = {
26
+ name: "task",
27
+ description,
28
+ parameters: Type.Object({
29
+ prompt: Type.String({
30
+ description:
31
+ "Detailed description of the work to perform. Be specific about expected outputs and any constraints.",
32
+ }),
33
+ }),
34
+ };
35
+
36
+ export async function* runTaskTool(
37
+ options: CliOptions,
38
+ args: Record<string, any>,
39
+ signal?: AbortSignal,
40
+ ): AsyncGenerator<ToolRunnerEvent> {
41
+ const tools: ToolAndRunner[] = [
42
+ { tool: bash, runner: runBashTool },
43
+ { tool: edit, runner: runEditTool },
44
+ ];
45
+ const messages: Message[] = [
46
+ { role: "user", content: args.prompt || "", timestamp: Date.now() },
47
+ ];
48
+
49
+ const systemPrompt = await buildSystemPrompt(TASK_PROMPT);
50
+ const ctx: AgentContex = {
51
+ systemPrompt,
52
+ tools,
53
+ messages,
54
+ options,
55
+ signal,
56
+ };
57
+
58
+ let output = "";
59
+ const agent = streamAgent(ctx);
60
+ for await (const ev of agent) {
61
+ switch (ev.type) {
62
+ case "message_start": {
63
+ const text = `[${formatTimestamp(ev.partial.timestamp)}] Working...`;
64
+ output += text;
65
+ yield { type: "output", text };
66
+ break;
67
+ }
68
+ case "message_end": {
69
+ const thinking = ev.message.content
70
+ .filter((b) => b.type === "thinking")
71
+ .map((b) => b.thinking)
72
+ .join("");
73
+ const messageText = ev.message.content
74
+ .filter((b) => b.type === "text")
75
+ .map((b) => b.text)
76
+ .join("");
77
+ const calls = ev.message.content
78
+ .filter((b) => b.type === "toolCall")
79
+ .map((b) => b.name);
80
+
81
+ let text = `[${formatTimestamp(ev.message.timestamp)}]`;
82
+ if (thinking.length > 0) {
83
+ const thinkingTokens = estimateTokens(thinking);
84
+ text += `Thinking... (${thinkingTokens} tokens)`;
85
+ }
86
+ if (messageText.length > 0) {
87
+ text += `\n\n${messageText}\n`;
88
+ }
89
+ if (calls.length > 0) {
90
+ text += ` \nTool calls:`;
91
+ for (const c of calls) {
92
+ text += `\n${c}`;
93
+ }
94
+ }
95
+
96
+ output += text;
97
+ yield { type: "output", text };
98
+ break;
99
+ }
100
+ case "tool_message_end": {
101
+ const ts = formatTimestamp(ev.message.timestamp);
102
+ const name = ev.message.toolName;
103
+ const symbol = !ev.message.isError ? "✓ " : "✗ ";
104
+ const text = `[${ts}] ${name} ${symbol}`;
105
+
106
+ output += text;
107
+ yield { type: "output", text };
108
+ break;
109
+ }
110
+ }
111
+ }
112
+
113
+ yield { type: "result", text: output };
114
+ }
@@ -0,0 +1,150 @@
1
+ import { type Color, HStack, Text } from "@cel-tui/core";
2
+ import { onceEvery } from "./shared";
3
+ import type { TUIState } from "./types";
4
+
5
+ export const theme = {
6
+ black: "color00" as Color,
7
+ bblack: "color08" as Color,
8
+
9
+ red: "color01" as Color,
10
+ bred: "color09" as Color,
11
+
12
+ green: "color02" as Color,
13
+ bgreen: "color10" as Color,
14
+
15
+ yellow: "color03" as Color,
16
+ byellow: "color11" as Color,
17
+
18
+ blue: "color04" as Color,
19
+ bblue: "color12" as Color,
20
+
21
+ magenta: "color05" as Color,
22
+ bmagenta: "color13" as Color,
23
+
24
+ cyan: "color06" as Color,
25
+ bcyan: "color14" as Color,
26
+
27
+ white: "color07" as Color,
28
+ bwhite: "color15" as Color,
29
+ };
30
+
31
+ export function TextPill(
32
+ content: string,
33
+ fgColor: Color,
34
+ bgColor: Color,
35
+ size?: number | undefined,
36
+ ) {
37
+ return HStack({ gap: 1, width: size }, [
38
+ HStack({ bgColor, padding: { x: 1 } }, [
39
+ Text(content, { bold: true, fgColor }),
40
+ ]),
41
+ ]);
42
+ }
43
+
44
+ export function Spinner() {
45
+ const spinnerFrames = [
46
+ "⠁",
47
+ "⠂",
48
+ "⠄",
49
+ "⡀",
50
+ "⡈",
51
+ "⡐",
52
+ "⡠",
53
+ "⣀",
54
+ "⣁",
55
+ "⣂",
56
+ "⣄",
57
+ "⣌",
58
+ "⣔",
59
+ "⣤",
60
+ "⣥",
61
+ "⣦",
62
+ "⣮",
63
+ "⣶",
64
+ "⣷",
65
+ "⣿",
66
+ "⡿",
67
+ "⠿",
68
+ "⢟",
69
+ "⠟",
70
+ "⡛",
71
+ "⠛",
72
+ "⠫",
73
+ "⢋",
74
+ "⠋",
75
+ "⠍",
76
+ "⡉",
77
+ "⠉",
78
+ "⠑",
79
+ "⠡",
80
+ "⢁",
81
+ ];
82
+ let spinnerTick = 0;
83
+ const spinnerEvery = onceEvery(4, () => spinnerTick++);
84
+ const currentSpinner = () =>
85
+ spinnerFrames[spinnerTick % spinnerFrames.length];
86
+
87
+ return { spinnerEvery, currentSpinner };
88
+ }
89
+
90
+ export function ActivityPill(state: TUIState, spinnerFrame: string) {
91
+ let label = "idle";
92
+ const frame = state.streaming ? spinnerFrame : "";
93
+
94
+ if (frame) {
95
+ label = frame;
96
+ }
97
+
98
+ return Text(label);
99
+ }
100
+
101
+ export function ContextPill(state: TUIState) {
102
+ let text = "";
103
+ let bg = theme.bgreen;
104
+ if (!state.contextSize) {
105
+ text = "0%";
106
+ bg = theme.bwhite;
107
+ } else {
108
+ // Colors show the progress in the "smart window" or how much
109
+ // before the dumb zone. The user facing percentage is the model
110
+ // amount. An elegant way to show both :)
111
+ const max = state.options.model.contextWindow;
112
+ const smartMax = 80000;
113
+ const percent = Math.floor((state.contextSize / max) * 100);
114
+ const smartPercent = Math.floor((state.contextSize / smartMax) * 100);
115
+ if (smartPercent > 90) {
116
+ bg = theme.bred;
117
+ } else if (smartPercent > 80) {
118
+ bg = theme.red;
119
+ } else if (smartPercent > 70) {
120
+ bg = theme.yellow;
121
+ } else if (smartPercent > 50) {
122
+ bg = theme.byellow;
123
+ }
124
+ text = `~${percent}%`;
125
+ }
126
+
127
+ text += ` (${state.options.model.contextWindow / 1000}k)`;
128
+
129
+ return TextPill(text, theme.bblack, bg);
130
+ }
131
+
132
+ export function GitPill(state: TUIState) {
133
+ return TextPill(state.gitBranch ?? "No git.", theme.bwhite, theme.bblack);
134
+ }
135
+
136
+ export function ModelPill(state: TUIState) {
137
+ // Like loot: Gold/purple/blue/green/white -> xhigh/high/medium/low/minimal
138
+ switch (state.options.effort) {
139
+ case "xhigh":
140
+ return TextPill(state.options.model.name, theme.white, theme.yellow);
141
+ case "high":
142
+ return TextPill(state.options.model.name, theme.white, theme.magenta);
143
+ case "medium":
144
+ return TextPill(state.options.model.name, theme.white, theme.blue);
145
+ case "low":
146
+ return TextPill(state.options.model.name, theme.white, theme.green);
147
+ }
148
+ // Minimal
149
+ return TextPill(state.options.model.name, theme.bwhite, theme.bblack);
150
+ }