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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- 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
|
+
}
|