ashlrcode 1.0.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Keybindings — user-customizable keyboard shortcuts.
3
+ * Loaded from ~/.ashlrcode/keybindings.json
4
+ *
5
+ * Users can override any default binding by creating keybindings.json with
6
+ * an array of { key, action, description? } objects. Custom bindings are
7
+ * merged on top of defaults by action name.
8
+ */
9
+
10
+ import { existsSync } from "fs";
11
+ import { readFile, writeFile, mkdir } from "fs/promises";
12
+ import { join } from "path";
13
+ import { getConfigDir } from "../config/settings.ts";
14
+
15
+ export interface Keybinding {
16
+ key: string; // e.g., "ctrl+c", "ctrl+shift+k", "escape"
17
+ action: string; // e.g., "submit", "clear", "undo", "mode-switch", "compact"
18
+ description?: string;
19
+ }
20
+
21
+ // Default keybindings — these ship with AshlrCode and can be overridden.
22
+ const DEFAULT_BINDINGS: Keybinding[] = [
23
+ { key: "ctrl+c", action: "exit", description: "Exit AshlrCode" },
24
+ { key: "shift+tab", action: "mode-switch", description: "Cycle through modes" },
25
+ { key: "tab", action: "autocomplete", description: "Accept autocomplete suggestion" },
26
+ { key: "ctrl+l", action: "clear-screen", description: "Clear output" },
27
+ { key: "ctrl+z", action: "undo", description: "Undo last file change" },
28
+ { key: "ctrl+e", action: "effort-cycle", description: "Cycle effort level" },
29
+ { key: "ctrl+k", action: "compact", description: "Compact context" },
30
+ { key: "ctrl+u", action: "clear-input", description: "Clear input line" },
31
+ { key: "up", action: "history-prev", description: "Previous input" },
32
+ { key: "down", action: "history-next", description: "Next input" },
33
+ { key: "right", action: "autocomplete", description: "Accept autocomplete (arrow)" },
34
+ { key: "ctrl+v", action: "voice-toggle", description: "Toggle voice recording (push-to-talk)" },
35
+ ];
36
+
37
+ let bindings: Keybinding[] = [...DEFAULT_BINDINGS];
38
+
39
+ function getKeybindingsPath(): string {
40
+ return join(getConfigDir(), "keybindings.json");
41
+ }
42
+
43
+ /** Load keybindings from disk, merging with defaults */
44
+ export async function loadKeybindings(): Promise<void> {
45
+ const path = getKeybindingsPath();
46
+ if (!existsSync(path)) return;
47
+
48
+ try {
49
+ const raw = await readFile(path, "utf-8");
50
+ const custom = JSON.parse(raw) as Keybinding[];
51
+
52
+ // Custom bindings override defaults by action
53
+ const merged = new Map<string, Keybinding>();
54
+ for (const b of DEFAULT_BINDINGS) merged.set(b.action, b);
55
+ for (const b of custom) merged.set(b.action, b);
56
+
57
+ bindings = Array.from(merged.values());
58
+ } catch {
59
+ // Silently ignore malformed keybindings — fall back to defaults
60
+ }
61
+ }
62
+
63
+ /** Save current keybindings to disk */
64
+ export async function saveKeybindings(): Promise<void> {
65
+ await mkdir(getConfigDir(), { recursive: true });
66
+ await writeFile(getKeybindingsPath(), JSON.stringify(bindings, null, 2), "utf-8");
67
+ }
68
+
69
+ /** Build a normalized combo string from key event parts */
70
+ function buildCombo(key: string, ctrl: boolean, shift: boolean, meta: boolean): string {
71
+ const parts: string[] = [];
72
+ if (ctrl) parts.push("ctrl");
73
+ if (shift) parts.push("shift");
74
+ if (meta) parts.push("meta");
75
+ parts.push(key.toLowerCase());
76
+ return parts.join("+");
77
+ }
78
+
79
+ /** Get the action for a key combo, or null if no binding */
80
+ export function getAction(key: string, ctrl: boolean, shift: boolean, meta: boolean): string | null {
81
+ const combo = buildCombo(key, ctrl, shift, meta);
82
+ const binding = bindings.find(b => b.key === combo);
83
+ return binding?.action ?? null;
84
+ }
85
+
86
+ /** Get all bindings (for /keybindings command) */
87
+ export function getBindings(): readonly Keybinding[] {
88
+ return bindings;
89
+ }
90
+
91
+ /** Update a single binding by action name */
92
+ export function setBinding(action: string, key: string): void {
93
+ const existing = bindings.find(b => b.action === action);
94
+ if (existing) {
95
+ existing.key = key;
96
+ } else {
97
+ bindings.push({ key, action });
98
+ }
99
+ }
100
+
101
+ /** Reset all bindings to defaults */
102
+ export function resetBindings(): void {
103
+ bindings = [...DEFAULT_BINDINGS];
104
+ }
105
+
106
+ /**
107
+ * Input history — remembers past user inputs for up/down arrow navigation.
108
+ */
109
+ export class InputHistory {
110
+ private history: string[] = [];
111
+ private index = -1;
112
+
113
+ push(input: string): void {
114
+ if (input && input !== this.history[this.history.length - 1]) {
115
+ this.history.push(input);
116
+ }
117
+ this.index = -1; // Reset to bottom
118
+ }
119
+
120
+ prev(_current: string): string | null {
121
+ if (this.history.length === 0) return null;
122
+ if (this.index === -1) {
123
+ this.index = this.history.length - 1;
124
+ } else if (this.index > 0) {
125
+ this.index--;
126
+ }
127
+ return this.history[this.index] ?? null;
128
+ }
129
+
130
+ next(): string | null {
131
+ if (this.index === -1) return null;
132
+ this.index++;
133
+ if (this.index >= this.history.length) {
134
+ this.index = -1;
135
+ return ""; // Clear input when going past end
136
+ }
137
+ return this.history[this.index] ?? null;
138
+ }
139
+
140
+ reset(): void {
141
+ this.index = -1;
142
+ }
143
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Markdown-lite renderer — transforms streaming text with chalk formatting.
3
+ *
4
+ * Handles: **bold**, `inline code`, ```code blocks```, # headers, - lists
5
+ * Works with streaming text (processes complete lines).
6
+ */
7
+
8
+ import chalk from "chalk";
9
+
10
+ interface RenderState {
11
+ inCodeBlock: boolean;
12
+ codeBlockLang: string;
13
+ codeBlockLines: string[];
14
+ buffer: string;
15
+ }
16
+
17
+ const state: RenderState = {
18
+ inCodeBlock: false,
19
+ codeBlockLang: "",
20
+ codeBlockLines: [],
21
+ buffer: "",
22
+ };
23
+
24
+ /**
25
+ * Process a text delta from the stream.
26
+ * Buffers until complete lines, then renders with formatting.
27
+ */
28
+ export function renderMarkdownDelta(delta: string): string {
29
+ state.buffer += delta;
30
+
31
+ // Only process complete lines (wait for \n)
32
+ const lastNewline = state.buffer.lastIndexOf("\n");
33
+ if (lastNewline === -1) {
34
+ return ""; // Buffer until we have a complete line
35
+ }
36
+
37
+ // Process complete lines, keep remainder in buffer
38
+ const complete = state.buffer.slice(0, lastNewline);
39
+ state.buffer = state.buffer.slice(lastNewline + 1);
40
+
41
+ const lines = complete.split("\n");
42
+ const rendered = lines.map(renderLine).join("\n");
43
+
44
+ return rendered + "\n";
45
+ }
46
+
47
+ /**
48
+ * Flush any remaining buffered content.
49
+ */
50
+ export function flushMarkdown(): string {
51
+ if (state.buffer) {
52
+ const result = renderLine(state.buffer);
53
+ state.buffer = "";
54
+ return result;
55
+ }
56
+ return "";
57
+ }
58
+
59
+ /**
60
+ * Reset renderer state (call between turns).
61
+ */
62
+ export function resetMarkdown(): void {
63
+ state.inCodeBlock = false;
64
+ state.codeBlockLang = "";
65
+ state.codeBlockLines = [];
66
+ state.buffer = "";
67
+ }
68
+
69
+ function renderLine(line: string): string {
70
+ // Code block toggles
71
+ if (line.trimStart().startsWith("```")) {
72
+ if (state.inCodeBlock) {
73
+ state.inCodeBlock = false;
74
+ state.codeBlockLang = "";
75
+ state.codeBlockLines = [];
76
+ return chalk.dim("```");
77
+ } else {
78
+ state.inCodeBlock = true;
79
+ state.codeBlockLang = line.trim().slice(3).trim();
80
+ state.codeBlockLines = [];
81
+ const langLabel = state.codeBlockLang
82
+ ? chalk.dim(`\`\`\`${state.codeBlockLang}`)
83
+ : chalk.dim("```");
84
+ return langLabel;
85
+ }
86
+ }
87
+
88
+ // Inside code block — syntax highlight
89
+ if (state.inCodeBlock) {
90
+ state.codeBlockLines.push(line);
91
+ const lineNum = state.codeBlockLines.length;
92
+ const highlighted = highlightCode(line, state.codeBlockLang);
93
+ const numStr = chalk.hex("#616161")(`${String(lineNum).padStart(3)} │ `);
94
+ return numStr + highlighted;
95
+ }
96
+
97
+ // Headers
98
+ if (line.startsWith("### ")) {
99
+ return chalk.bold(line.slice(4));
100
+ }
101
+ if (line.startsWith("## ")) {
102
+ return chalk.bold.underline(line.slice(3));
103
+ }
104
+ if (line.startsWith("# ")) {
105
+ return chalk.bold.underline(line.slice(2));
106
+ }
107
+
108
+ // Bullet lists
109
+ if (line.match(/^\s*[-*]\s/)) {
110
+ return line.replace(/^(\s*)([-*])(\s)/, "$1" + chalk.cyan("•") + "$3");
111
+ }
112
+
113
+ // Numbered lists
114
+ if (line.match(/^\s*\d+\.\s/)) {
115
+ return line.replace(/^(\s*)(\d+\.)(\s)/, "$1" + chalk.cyan("$2") + "$3");
116
+ }
117
+
118
+ // Inline formatting
119
+ return renderInline(line);
120
+ }
121
+
122
+ /**
123
+ * Apply regex-based syntax highlighting to a code line.
124
+ * Supports JS/TS, Python, Bash, Go, Rust, JSON, and diff.
125
+ */
126
+ function highlightCode(line: string, lang: string): string {
127
+ // Diff highlighting — applied by lang or line prefix
128
+ if (lang === "diff" || (lang === "" && /^[+\-@]/.test(line))) {
129
+ if (line.startsWith("+")) return chalk.hex("#00E676")(line);
130
+ if (line.startsWith("-")) return chalk.hex("#FF1744")(line);
131
+ if (line.startsWith("@")) return chalk.hex("#82B1FF")(line);
132
+ return chalk.dim(line);
133
+ }
134
+
135
+ // JSON — minimal highlighting (strings, numbers, booleans/null)
136
+ if (lang === "json") {
137
+ let result = line;
138
+ // String values (keys and values)
139
+ result = result.replace(/"(?:[^"\\]|\\.)*"/g, (m) => chalk.hex("#00E676")(m));
140
+ // Numbers
141
+ result = result.replace(/\b(\d+\.?\d*)\b/g, (m) => chalk.hex("#FFD54F")(m));
142
+ // Booleans and null
143
+ result = result.replace(/\b(true|false|null)\b/g, (m) => chalk.hex("#00E5FF")(m));
144
+ return result;
145
+ }
146
+
147
+ // Use a token-based approach to avoid highlighting inside strings/comments
148
+ const tokens: { start: number; end: number; styled: string }[] = [];
149
+
150
+ // Collect string spans first (they take priority)
151
+ const strings = /(["'`])(?:(?!\1|\\).|\\.)*\1/g;
152
+ let match: RegExpExecArray | null;
153
+ while ((match = strings.exec(line)) !== null) {
154
+ tokens.push({
155
+ start: match.index,
156
+ end: match.index + match[0].length,
157
+ styled: chalk.hex("#00E676")(match[0]),
158
+ });
159
+ }
160
+
161
+ // Collect comment spans
162
+ const commentPattern =
163
+ lang === "python" || lang === "bash" || lang === "sh"
164
+ ? /#.*$/gm
165
+ : /\/\/.*$/gm;
166
+ while ((match = commentPattern.exec(line)) !== null) {
167
+ // Only add if not overlapping with a string token
168
+ const s = match.index;
169
+ const e = match.index + match[0].length;
170
+ if (!tokens.some((t) => s >= t.start && s < t.end)) {
171
+ tokens.push({ start: s, end: e, styled: chalk.hex("#546E7A")(match[0]) });
172
+ }
173
+ }
174
+
175
+ // Pick keyword set based on language
176
+ let keywordPattern: RegExp | null = null;
177
+ const jsLangs = ["typescript", "ts", "javascript", "js", "jsx", "tsx"];
178
+ const pyLangs = ["python", "py"];
179
+ const bashLangs = ["bash", "sh", "shell", "zsh"];
180
+ const goLangs = ["go", "golang"];
181
+ const rustLangs = ["rust", "rs"];
182
+
183
+ if (jsLangs.includes(lang)) {
184
+ keywordPattern =
185
+ /\b(const|let|var|function|class|if|else|for|while|return|import|export|from|async|await|new|this|typeof|interface|type|enum|extends|implements|try|catch|throw|switch|case|default|break|continue|of|in|yield|void|delete|instanceof)\b/g;
186
+ } else if (pyLangs.includes(lang)) {
187
+ keywordPattern =
188
+ /\b(def|class|if|elif|else|for|while|return|import|from|as|try|except|raise|with|yield|lambda|pass|break|continue|and|or|not|in|is|True|False|None|async|await)\b/g;
189
+ } else if (bashLangs.includes(lang)) {
190
+ keywordPattern =
191
+ /\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|local|export|source|echo|exit|in|select|until)\b/g;
192
+ } else if (goLangs.includes(lang)) {
193
+ keywordPattern =
194
+ /\b(func|package|import|var|const|type|struct|interface|map|chan|go|defer|return|if|else|for|range|switch|case|default|break|continue|select|fallthrough)\b/g;
195
+ } else if (rustLangs.includes(lang)) {
196
+ keywordPattern =
197
+ /\b(fn|let|mut|const|pub|mod|use|struct|enum|impl|trait|where|match|if|else|for|while|loop|return|break|continue|move|async|await|unsafe|extern|crate|self|super|type|as|in|ref|dyn)\b/g;
198
+ }
199
+
200
+ // Collect keyword spans
201
+ if (keywordPattern) {
202
+ while ((match = keywordPattern.exec(line)) !== null) {
203
+ const s = match.index;
204
+ const e = match.index + match[0].length;
205
+ if (!tokens.some((t) => s >= t.start && s < t.end)) {
206
+ tokens.push({
207
+ start: s,
208
+ end: e,
209
+ styled: chalk.hex("#00E5FF")(match[0]),
210
+ });
211
+ }
212
+ }
213
+ }
214
+
215
+ // Type/class names (PascalCase identifiers)
216
+ const typePattern = /\b([A-Z][a-zA-Z0-9]*)\b/g;
217
+ while ((match = typePattern.exec(line)) !== null) {
218
+ const s = match.index;
219
+ const e = match.index + match[0].length;
220
+ if (!tokens.some((t) => s >= t.start && s < t.end)) {
221
+ tokens.push({
222
+ start: s,
223
+ end: e,
224
+ styled: chalk.hex("#E040FB")(match[0]),
225
+ });
226
+ }
227
+ }
228
+
229
+ // Numbers
230
+ const numbers = /\b(\d+\.?\d*)\b/g;
231
+ while ((match = numbers.exec(line)) !== null) {
232
+ const s = match.index;
233
+ const e = match.index + match[0].length;
234
+ if (!tokens.some((t) => s >= t.start && s < t.end)) {
235
+ tokens.push({
236
+ start: s,
237
+ end: e,
238
+ styled: chalk.hex("#FFD54F")(match[0]),
239
+ });
240
+ }
241
+ }
242
+
243
+ // If no tokens matched, return line as-is
244
+ if (tokens.length === 0) return line;
245
+
246
+ // Sort tokens by start position and reconstruct the line
247
+ tokens.sort((a, b) => a.start - b.start);
248
+ let result = "";
249
+ let cursor = 0;
250
+ for (const token of tokens) {
251
+ if (token.start > cursor) {
252
+ result += line.slice(cursor, token.start);
253
+ }
254
+ result += token.styled;
255
+ cursor = token.end;
256
+ }
257
+ if (cursor < line.length) {
258
+ result += line.slice(cursor);
259
+ }
260
+ return result;
261
+ }
262
+
263
+ function renderInline(text: string): string {
264
+ // Bold: **text** — use replacer function so chalk wraps the captured group
265
+ text = text.replace(/\*\*([^*]+)\*\*/g, (_match, g1) => chalk.bold(g1));
266
+
267
+ // Inline code: `text`
268
+ text = text.replace(/`([^`]+)`/g, (_match, g1) => chalk.cyan(`\`${g1}\``));
269
+
270
+ return text;
271
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Message rendering — format tool output with grouping and progress.
3
+ */
4
+
5
+ import { theme } from "./theme.ts";
6
+
7
+ /** Format a tool execution for display with icon and timing */
8
+ export function formatToolExecution(name: string, input: Record<string, unknown>, result: string, isError: boolean, durationMs?: number): string[] {
9
+ const lines: string[] = [];
10
+ const icon = isError ? theme.error("✗") : theme.success("✓");
11
+ const timing = durationMs ? theme.muted(` (${formatDuration(durationMs)})`) : "";
12
+
13
+ // Tool header
14
+ lines.push(` ${theme.toolIcon("◆")} ${theme.toolName(name)}${timing}`);
15
+
16
+ // Input preview (context-aware)
17
+ const preview = getInputPreview(name, input);
18
+ if (preview) lines.push(theme.tertiary(` ${preview}`));
19
+
20
+ // Result preview (first line, truncated)
21
+ const resultPreview = result.split("\n")[0]?.slice(0, 100) ?? "";
22
+ const extra = result.split("\n").length > 1 ? theme.muted(` (+${result.split("\n").length - 1} lines)`) : "";
23
+ lines.push(` ${icon} ${theme.toolResult(resultPreview)}${extra}`);
24
+
25
+ return lines;
26
+ }
27
+
28
+ /** Format a group of tool executions (parallel tools) */
29
+ export function formatToolGroup(tools: Array<{ name: string; result: string; isError: boolean; durationMs?: number }>): string[] {
30
+ if (tools.length <= 1) return [];
31
+
32
+ const lines: string[] = [];
33
+ lines.push(theme.muted(` ┌ ${tools.length} tools executed in parallel`));
34
+ for (const t of tools) {
35
+ const icon = t.isError ? theme.error("✗") : theme.success("✓");
36
+ lines.push(` │ ${icon} ${t.name} ${theme.muted(t.durationMs ? `(${formatDuration(t.durationMs)})` : "")}`);
37
+ }
38
+ lines.push(theme.muted(" └"));
39
+ return lines;
40
+ }
41
+
42
+ /** Get a smart input preview for a tool */
43
+ function getInputPreview(name: string, input: Record<string, unknown>): string {
44
+ switch (name) {
45
+ case "Bash": return `$ ${String(input.command ?? "").slice(0, 80)}`;
46
+ case "Read": return String(input.file_path ?? "");
47
+ case "Write": return `-> ${String(input.file_path ?? "")}`;
48
+ case "Edit": return `~ ${String(input.file_path ?? "")}`;
49
+ case "Glob": return `pattern: ${String(input.pattern ?? "")}`;
50
+ case "Grep": return `/${String(input.pattern ?? "")}/`;
51
+ case "WebFetch": return String(input.url ?? "").slice(0, 60);
52
+ case "Agent": return `agent: ${String(input.description ?? "")}`;
53
+ case "LSP": return `${input.action} ${String(input.file ?? "")}:${input.line}`;
54
+ default: {
55
+ const first = Object.entries(input)[0];
56
+ return first ? `${first[0]}: ${String(first[1]).slice(0, 60)}` : "";
57
+ }
58
+ }
59
+ }
60
+
61
+ function formatDuration(ms: number): string {
62
+ if (ms < 1000) return `${ms}ms`;
63
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
64
+ return `${(ms / 60_000).toFixed(1)}m`;
65
+ }
66
+
67
+ /** Format a turn separator with stats */
68
+ export function formatTurnSeparator(turnNumber: number, cost: number, buddyName: string, toolCount: number): string {
69
+ const parts = [`turn ${turnNumber}`, `$${cost.toFixed(4)}`];
70
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
71
+ parts.push(buddyName);
72
+ return theme.muted(`\n ── ${parts.join(" · ")} ──\n`);
73
+ }
package/src/ui/mode.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Mode management — cycle through Normal / Plan / Accept Edits / YOLO.
3
+ * Shift+Tab (escape sequence \x1b[Z) cycles modes.
4
+ */
5
+
6
+ import { theme } from "./theme.ts";
7
+ import {
8
+ setBypassMode,
9
+ setAutoAcceptEdits,
10
+ } from "../config/permissions.ts";
11
+ import {
12
+ enterPlanMode,
13
+ exitPlanMode,
14
+ isPlanMode,
15
+ } from "../planning/plan-mode.ts";
16
+
17
+ export type Mode = "normal" | "plan" | "accept-edits" | "yolo";
18
+
19
+ let currentMode: Mode = "normal";
20
+
21
+ const MODE_ORDER: Mode[] = ["normal", "plan", "accept-edits", "yolo"];
22
+
23
+ export function getCurrentMode(): Mode {
24
+ return currentMode;
25
+ }
26
+
27
+ export function setMode(mode: Mode): void {
28
+ // Deactivate previous mode
29
+ switch (currentMode) {
30
+ case "plan":
31
+ if (isPlanMode()) exitPlanMode();
32
+ break;
33
+ case "accept-edits":
34
+ setAutoAcceptEdits(false);
35
+ break;
36
+ case "yolo":
37
+ setBypassMode(false);
38
+ break;
39
+ }
40
+
41
+ currentMode = mode;
42
+
43
+ // Activate new mode
44
+ switch (mode) {
45
+ case "plan":
46
+ enterPlanMode();
47
+ break;
48
+ case "accept-edits":
49
+ setAutoAcceptEdits(true);
50
+ break;
51
+ case "yolo":
52
+ setBypassMode(true);
53
+ break;
54
+ }
55
+ }
56
+
57
+ export function cycleMode(): Mode {
58
+ const currentIndex = MODE_ORDER.indexOf(currentMode);
59
+ const nextIndex = (currentIndex + 1) % MODE_ORDER.length;
60
+ const nextMode = MODE_ORDER[nextIndex]!;
61
+ setMode(nextMode);
62
+ return nextMode;
63
+ }
64
+
65
+ export function getPromptForMode(): string {
66
+ return theme.prompt[currentMode === "accept-edits" ? "edits" : currentMode];
67
+ }
68
+
69
+ export function getModeLabel(): string {
70
+ switch (currentMode) {
71
+ case "normal":
72
+ return "";
73
+ case "plan":
74
+ return theme.plan("PLAN");
75
+ case "accept-edits":
76
+ return theme.warning("EDITS");
77
+ case "yolo":
78
+ return theme.error("YOLO");
79
+ }
80
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Desktop notifications — alert users when tasks complete.
3
+ * Uses platform-native notification APIs.
4
+ */
5
+
6
+ let _enabled = true;
7
+
8
+ export function setNotificationsEnabled(enabled: boolean): void {
9
+ _enabled = enabled;
10
+ }
11
+
12
+ /**
13
+ * Send a desktop notification.
14
+ */
15
+ export async function sendNotification(title: string, body: string): Promise<void> {
16
+ if (!_enabled) return;
17
+
18
+ try {
19
+ const platform = process.platform;
20
+
21
+ if (platform === "darwin") {
22
+ // macOS: osascript
23
+ const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
24
+ const proc = Bun.spawn(["osascript", "-e", script], { stdout: "pipe", stderr: "pipe" });
25
+ await proc.exited;
26
+ } else if (platform === "linux") {
27
+ // Linux: notify-send
28
+ const proc = Bun.spawn(["notify-send", title, body], { stdout: "pipe", stderr: "pipe" });
29
+ await proc.exited;
30
+ } else if (platform === "win32") {
31
+ // Windows: PowerShell toast — escape single quotes to prevent injection
32
+ const safeTitle = title.replace(/'/g, "''");
33
+ const safeBody = body.replace(/'/g, "''");
34
+ const script = `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); $text = $xml.GetElementsByTagName('text'); $text[0].AppendChild($xml.CreateTextNode('${safeTitle}')); $text[1].AppendChild($xml.CreateTextNode('${safeBody}')); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('AshlrCode').Show([Windows.UI.Notifications.ToastNotification]::new($xml))`;
35
+ const proc = Bun.spawn(["powershell", "-Command", script], { stdout: "pipe", stderr: "pipe" });
36
+ await proc.exited;
37
+ }
38
+ } catch {} // Never crash on notification failure
39
+ }
40
+
41
+ /**
42
+ * Notify on turn completion (when terminal is unfocused).
43
+ */
44
+ export async function notifyTurnComplete(toolCount: number, durationMs: number): Promise<void> {
45
+ const seconds = Math.round(durationMs / 1000);
46
+ const body = toolCount > 0
47
+ ? `Completed with ${toolCount} tool calls (${seconds}s)`
48
+ : `Response ready (${seconds}s)`;
49
+ await sendNotification("AshlrCode", body);
50
+ }
51
+
52
+ /**
53
+ * Notify on error.
54
+ */
55
+ export async function notifyError(message: string): Promise<void> {
56
+ await sendNotification("AshlrCode Error", message.slice(0, 100));
57
+ }