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
|
+
* Worktree tools — git worktree isolation for safe parallel edits.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import type { Tool, ToolContext } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const enterWorktreeTool: Tool = {
|
|
9
|
+
name: "EnterWorktree",
|
|
10
|
+
|
|
11
|
+
prompt() {
|
|
12
|
+
return "Create an isolated git worktree for safe parallel editing. Returns the worktree path. Use with Agent tool for isolated sub-agent work.";
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
inputSchema() {
|
|
16
|
+
return {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
name: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Name for the worktree branch (auto-generated if omitted)",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: [],
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
isReadOnly() {
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
isDestructive() {
|
|
32
|
+
return false;
|
|
33
|
+
},
|
|
34
|
+
isConcurrencySafe() {
|
|
35
|
+
return false;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
validateInput() {
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async call(input, context) {
|
|
43
|
+
const name = (input.name as string) ?? `ac-worktree-${randomUUID().slice(0, 8)}`;
|
|
44
|
+
const worktreePath = `${context.cwd}/.ashlrcode-worktrees/${name}`;
|
|
45
|
+
|
|
46
|
+
const proc = Bun.spawn(
|
|
47
|
+
["git", "worktree", "add", "-b", name, worktreePath],
|
|
48
|
+
{ cwd: context.cwd, stdout: "pipe", stderr: "pipe" }
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const stderr = await new Response(proc.stderr).text();
|
|
52
|
+
const exitCode = await proc.exited;
|
|
53
|
+
|
|
54
|
+
if (exitCode !== 0) {
|
|
55
|
+
return `Failed to create worktree: ${stderr}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return `Worktree created at: ${worktreePath}\nBranch: ${name}\n\nUse this path as the working directory for isolated operations.`;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const exitWorktreeTool: Tool = {
|
|
63
|
+
name: "ExitWorktree",
|
|
64
|
+
|
|
65
|
+
prompt() {
|
|
66
|
+
return "Remove a git worktree. Optionally merge changes back to the original branch.";
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
inputSchema() {
|
|
70
|
+
return {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
path: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "Path to the worktree to remove",
|
|
76
|
+
},
|
|
77
|
+
merge: {
|
|
78
|
+
type: "boolean",
|
|
79
|
+
description: "Merge the worktree branch back before removing (default: false)",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["path"],
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
isReadOnly() {
|
|
87
|
+
return false;
|
|
88
|
+
},
|
|
89
|
+
isDestructive() {
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
isConcurrencySafe() {
|
|
93
|
+
return false;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
validateInput(input) {
|
|
97
|
+
if (!input.path) return "path is required";
|
|
98
|
+
return null;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async call(input, context) {
|
|
102
|
+
const worktreePath = input.path as string;
|
|
103
|
+
const merge = (input.merge as boolean) ?? false;
|
|
104
|
+
|
|
105
|
+
if (merge) {
|
|
106
|
+
// Get the branch name from the worktree
|
|
107
|
+
const branchProc = Bun.spawn(
|
|
108
|
+
["git", "-C", worktreePath, "branch", "--show-current"],
|
|
109
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
110
|
+
);
|
|
111
|
+
const branch = (await new Response(branchProc.stdout).text()).trim();
|
|
112
|
+
|
|
113
|
+
if (branch) {
|
|
114
|
+
// Merge the branch
|
|
115
|
+
const mergeProc = Bun.spawn(
|
|
116
|
+
["git", "merge", branch, "--no-edit"],
|
|
117
|
+
{ cwd: context.cwd, stdout: "pipe", stderr: "pipe" }
|
|
118
|
+
);
|
|
119
|
+
const mergeStderr = await new Response(mergeProc.stderr).text();
|
|
120
|
+
const mergeExit = await mergeProc.exited;
|
|
121
|
+
|
|
122
|
+
if (mergeExit !== 0) {
|
|
123
|
+
return `Merge failed: ${mergeStderr}\nWorktree NOT removed. Resolve conflicts manually.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Remove the worktree
|
|
129
|
+
const proc = Bun.spawn(
|
|
130
|
+
["git", "worktree", "remove", worktreePath, "--force"],
|
|
131
|
+
{ cwd: context.cwd, stdout: "pipe", stderr: "pipe" }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const stderr = await new Response(proc.stderr).text();
|
|
135
|
+
const exitCode = await proc.exited;
|
|
136
|
+
|
|
137
|
+
if (exitCode !== 0) {
|
|
138
|
+
return `Failed to remove worktree: ${stderr}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `Worktree removed: ${worktreePath}${merge ? " (changes merged)" : ""}`;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded types — compile-time safety for string types.
|
|
3
|
+
* Prevents accidentally passing a SessionId where a SystemPrompt is expected.
|
|
4
|
+
*
|
|
5
|
+
* Usage: wrap raw strings with the `as*` helpers when creating values,
|
|
6
|
+
* then use the branded type in function signatures for type-safe APIs.
|
|
7
|
+
*
|
|
8
|
+
* Not yet adopted in existing code — available for new code going forward.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
declare const __brand: unique symbol;
|
|
12
|
+
type Brand<T, B> = T & { readonly [__brand]: B };
|
|
13
|
+
|
|
14
|
+
export type SystemPrompt = Brand<string, "SystemPrompt">;
|
|
15
|
+
export type SessionId = Brand<string, "SessionId">;
|
|
16
|
+
export type AgentId = Brand<string, "AgentId">;
|
|
17
|
+
export type ToolName = Brand<string, "ToolName">;
|
|
18
|
+
|
|
19
|
+
export function asSystemPrompt(s: string): SystemPrompt { return s as SystemPrompt; }
|
|
20
|
+
export function asSessionId(s: string): SessionId { return s as SessionId; }
|
|
21
|
+
export function asAgentId(s: string): AgentId { return s as AgentId; }
|
|
22
|
+
export function asToolName(s: string): ToolName { return s as ToolName; }
|
package/src/ui/App.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Ink REPL — clean layout.
|
|
3
|
+
*
|
|
4
|
+
* Output scrolls above. Buddy + bubble right-aligned above input.
|
|
5
|
+
* Full-width input box. Status line below.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useCallback } from "react";
|
|
9
|
+
import { Box, Text, Static, useInput, useApp } from "ink";
|
|
10
|
+
import TextInput from "ink-text-input";
|
|
11
|
+
import { BuddyPanel } from "./BuddyPanel.tsx";
|
|
12
|
+
import { getAction, type InputHistory } from "./keybindings.ts";
|
|
13
|
+
|
|
14
|
+
interface OutputItem { id: number; text: string; }
|
|
15
|
+
|
|
16
|
+
interface AppProps {
|
|
17
|
+
onSubmit: (text: string) => void;
|
|
18
|
+
onExit: () => void;
|
|
19
|
+
onModeSwitch: () => void;
|
|
20
|
+
onUndo?: () => void;
|
|
21
|
+
onEffortCycle?: () => void;
|
|
22
|
+
onCompact?: () => void;
|
|
23
|
+
onClearScreen?: () => void;
|
|
24
|
+
onVoiceToggle?: () => void;
|
|
25
|
+
inputHistory?: InputHistory;
|
|
26
|
+
mode: string;
|
|
27
|
+
modeColor: string;
|
|
28
|
+
contextPercent: number;
|
|
29
|
+
contextUsed: string;
|
|
30
|
+
contextLimit: string;
|
|
31
|
+
buddyName: string;
|
|
32
|
+
buddyQuip: string;
|
|
33
|
+
buddyQuipType: "quip" | "suggestion" | "reaction";
|
|
34
|
+
buddyArt: string[];
|
|
35
|
+
items: OutputItem[];
|
|
36
|
+
isProcessing: boolean;
|
|
37
|
+
spinnerText: string;
|
|
38
|
+
commands: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function App({
|
|
42
|
+
onSubmit, onExit, onModeSwitch, onUndo, onEffortCycle, onCompact, onClearScreen, onVoiceToggle,
|
|
43
|
+
inputHistory, mode, modeColor,
|
|
44
|
+
contextPercent, contextUsed, contextLimit,
|
|
45
|
+
buddyName, buddyQuip, buddyQuipType, buddyArt,
|
|
46
|
+
items, isProcessing, spinnerText, commands,
|
|
47
|
+
}: AppProps) {
|
|
48
|
+
const [input, setInput] = useState("");
|
|
49
|
+
const [inputKey, setInputKey] = useState(0); // Change key to force remount (resets cursor)
|
|
50
|
+
const { exit } = useApp();
|
|
51
|
+
const w = process.stdout.columns || 80;
|
|
52
|
+
|
|
53
|
+
const suggestion = input.startsWith("/") && input.length > 1
|
|
54
|
+
? commands.find(c => c.startsWith(input) && c !== input)
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
const handleSubmit = useCallback((value: string) => {
|
|
58
|
+
const text = value.trim();
|
|
59
|
+
setInput("");
|
|
60
|
+
setInputKey(k => k + 1); // Remount to reset cursor
|
|
61
|
+
if (text) onSubmit(text);
|
|
62
|
+
}, [onSubmit]);
|
|
63
|
+
|
|
64
|
+
const handleModeSwitch = useCallback(() => onModeSwitch(), [onModeSwitch]);
|
|
65
|
+
|
|
66
|
+
// Accept autocomplete: set value AND force remount to reset cursor to end
|
|
67
|
+
const acceptSuggestion = useCallback(() => {
|
|
68
|
+
if (!suggestion) return;
|
|
69
|
+
setInput(suggestion + " ");
|
|
70
|
+
setInputKey(k => k + 1); // Force TextInput remount — cursor goes to end
|
|
71
|
+
}, [suggestion]);
|
|
72
|
+
|
|
73
|
+
useInput(useCallback((ch: string, key: any) => {
|
|
74
|
+
// Map Ink key event to a normalized key name
|
|
75
|
+
const keyName = key.tab ? "tab" : key.upArrow ? "up" : key.downArrow ? "down"
|
|
76
|
+
: key.leftArrow ? "left" : key.rightArrow ? "right" : key.escape ? "escape"
|
|
77
|
+
: key.return ? "return" : key.backspace ? "backspace" : key.delete ? "delete"
|
|
78
|
+
: ch;
|
|
79
|
+
|
|
80
|
+
const action = getAction(keyName, !!key.ctrl, !!key.shift, !!key.meta);
|
|
81
|
+
|
|
82
|
+
switch (action) {
|
|
83
|
+
case "exit":
|
|
84
|
+
onExit(); exit(); return;
|
|
85
|
+
case "mode-switch":
|
|
86
|
+
handleModeSwitch(); return;
|
|
87
|
+
case "autocomplete":
|
|
88
|
+
if (suggestion && (key.tab || (key.rightArrow && input.length > 0))) {
|
|
89
|
+
acceptSuggestion();
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
case "history-prev":
|
|
93
|
+
if (inputHistory) {
|
|
94
|
+
const prev = inputHistory.prev(input);
|
|
95
|
+
if (prev !== null) { setInput(prev); setInputKey(k => k + 1); }
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
case "history-next":
|
|
99
|
+
if (inputHistory) {
|
|
100
|
+
const next = inputHistory.next();
|
|
101
|
+
if (next !== null) { setInput(next); setInputKey(k => k + 1); }
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
case "clear-input":
|
|
105
|
+
setInput(""); setInputKey(k => k + 1); return;
|
|
106
|
+
case "undo":
|
|
107
|
+
onUndo?.(); return;
|
|
108
|
+
case "effort-cycle":
|
|
109
|
+
onEffortCycle?.(); return;
|
|
110
|
+
case "compact":
|
|
111
|
+
onCompact?.(); return;
|
|
112
|
+
case "clear-screen":
|
|
113
|
+
onClearScreen?.(); return;
|
|
114
|
+
case "voice-toggle":
|
|
115
|
+
onVoiceToggle?.(); return;
|
|
116
|
+
}
|
|
117
|
+
}, [suggestion, input, handleModeSwitch, onExit, exit, acceptSuggestion, inputHistory, onUndo, onEffortCycle, onCompact, onClearScreen, onVoiceToggle]));
|
|
118
|
+
|
|
119
|
+
const barWidth = 10;
|
|
120
|
+
const filled = Math.round((contextPercent / 100) * barWidth);
|
|
121
|
+
const empty = barWidth - filled;
|
|
122
|
+
const ctxColor = contextPercent < 50 ? "green" : contextPercent < 75 ? "yellow" : "red";
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Box flexDirection="column">
|
|
126
|
+
{/* Scrollable output */}
|
|
127
|
+
<Static items={items}>
|
|
128
|
+
{(item) => <Text key={item.id}>{item.text}</Text>}
|
|
129
|
+
</Static>
|
|
130
|
+
|
|
131
|
+
{/* Spinner */}
|
|
132
|
+
{isProcessing && <Text dimColor> ⠋ {spinnerText}</Text>}
|
|
133
|
+
|
|
134
|
+
{/* Input box — full width */}
|
|
135
|
+
<Text dimColor>{"-".repeat(w)}</Text>
|
|
136
|
+
<Box>
|
|
137
|
+
<Text color={modeColor} bold>❯ </Text>
|
|
138
|
+
{isProcessing ? (
|
|
139
|
+
<Text dimColor>waiting for response...</Text>
|
|
140
|
+
) : (
|
|
141
|
+
<Box>
|
|
142
|
+
<TextInput
|
|
143
|
+
key={inputKey}
|
|
144
|
+
value={input}
|
|
145
|
+
onChange={setInput}
|
|
146
|
+
onSubmit={handleSubmit}
|
|
147
|
+
placeholder="Type a message..."
|
|
148
|
+
/>
|
|
149
|
+
{suggestion && <Text dimColor>{suggestion.slice(input.length)}</Text>}
|
|
150
|
+
</Box>
|
|
151
|
+
)}
|
|
152
|
+
</Box>
|
|
153
|
+
{/* Autocomplete hints */}
|
|
154
|
+
{input.startsWith("/") && input.length > 1 && !isProcessing && (
|
|
155
|
+
<Box marginLeft={2}>
|
|
156
|
+
<Text dimColor>{commands.filter(c => c.startsWith(input)).slice(0, 5).join(" ")}</Text>
|
|
157
|
+
{suggestion && <Text dimColor italic> tab ↹</Text>}
|
|
158
|
+
</Box>
|
|
159
|
+
)}
|
|
160
|
+
<Text dimColor>{"-".repeat(w)}</Text>
|
|
161
|
+
|
|
162
|
+
{/* Bottom area: input status on left, buddy panel on right */}
|
|
163
|
+
<Box>
|
|
164
|
+
{/* Left: status line */}
|
|
165
|
+
<Box flexGrow={1}>
|
|
166
|
+
<Text color={modeColor} bold>❯❯ </Text>
|
|
167
|
+
<Text color={modeColor}>{mode}</Text>
|
|
168
|
+
<Text dimColor> (shift+tab)</Text>
|
|
169
|
+
<Text>{" "}</Text>
|
|
170
|
+
<Text color={ctxColor}>{"█".repeat(filled)}</Text>
|
|
171
|
+
<Text dimColor>{"░".repeat(empty)}</Text>
|
|
172
|
+
<Text> </Text>
|
|
173
|
+
<Text color={ctxColor}>{contextPercent}%</Text>
|
|
174
|
+
<Text dimColor> · {contextUsed}/{contextLimit}</Text>
|
|
175
|
+
</Box>
|
|
176
|
+
|
|
177
|
+
{/* Right: buddy panel — fixed width, fixed height */}
|
|
178
|
+
<Box width={20} flexShrink={0}>
|
|
179
|
+
<BuddyPanel art={buddyArt} name={buddyName} quip={buddyQuip} quipType={buddyQuipType} />
|
|
180
|
+
</Box>
|
|
181
|
+
</Box>
|
|
182
|
+
</Box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BuddyPanel — fixed-height Ink component for the buddy's ASCII art.
|
|
3
|
+
*
|
|
4
|
+
* Owns its own terminal region so Ink knows exactly how many lines to
|
|
5
|
+
* clear on re-render, preventing the duplication/flicker that plagued
|
|
6
|
+
* the previous inline approach.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { Box, Text } from "ink";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
art: string[];
|
|
14
|
+
name: string;
|
|
15
|
+
quip: string;
|
|
16
|
+
quipType: "quip" | "suggestion" | "reaction";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PANEL_WIDTH = 20;
|
|
20
|
+
// Suggestion quips have a "💡 " prefix (~3 cols); others have 1-col padding
|
|
21
|
+
const MAX_QUIP_WIDTH: Record<Props["quipType"], number> = {
|
|
22
|
+
suggestion: PANEL_WIDTH - 4,
|
|
23
|
+
reaction: PANEL_WIDTH - 1,
|
|
24
|
+
quip: PANEL_WIDTH - 2, // quotes add 2 chars but they're outside the text
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function BuddyPanel({ art, name, quip, quipType }: Props) {
|
|
28
|
+
// Fixed height = art lines + name line + quip line
|
|
29
|
+
const height = art.length + 2;
|
|
30
|
+
const maxWidth = MAX_QUIP_WIDTH[quipType];
|
|
31
|
+
const truncatedQuip = quip.length > maxWidth
|
|
32
|
+
? quip.slice(0, maxWidth - 1) + "…"
|
|
33
|
+
: quip;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column" alignItems="flex-end" height={height} flexShrink={0}>
|
|
37
|
+
{art.map((line, i) => <Text key={i} color="cyan">{line}</Text>)}
|
|
38
|
+
<Text color="cyan" bold>{name}</Text>
|
|
39
|
+
<QuipText quip={truncatedQuip} quipType={quipType} />
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function QuipText({ quip, quipType }: Pick<Props, "quip" | "quipType">) {
|
|
45
|
+
if (quipType === "suggestion") {
|
|
46
|
+
return <Text color="green">💡 {quip}</Text>;
|
|
47
|
+
}
|
|
48
|
+
if (quipType === "reaction") {
|
|
49
|
+
return <Text color="yellow">{quip}</Text>;
|
|
50
|
+
}
|
|
51
|
+
return <Text dimColor italic>"{quip}"</Text>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink component for tool permission prompts.
|
|
3
|
+
*
|
|
4
|
+
* Renders a styled permission request inline in the REPL output,
|
|
5
|
+
* showing the tool name, description, and available key options.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { Box, Text } from "ink";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
toolName: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PermissionPrompt({ toolName, description }: Props) {
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="column" marginY={1}>
|
|
19
|
+
<Box>
|
|
20
|
+
<Text color="yellow" bold>⚡ Permission: </Text>
|
|
21
|
+
<Text bold>{toolName}</Text>
|
|
22
|
+
</Box>
|
|
23
|
+
<Text dimColor> {description}</Text>
|
|
24
|
+
<Box marginTop={1}>
|
|
25
|
+
<Text dimColor> [y] allow [a] always [n] deny [d] always deny</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
</Box>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/ui/banner.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII art banner with Ashlr "A" mark for startup.
|
|
3
|
+
* Gradient coloring, branded, premium feel.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
|
|
8
|
+
// Ashlr "A" mark + AshlrCode text
|
|
9
|
+
const LOGO = [
|
|
10
|
+
" ╱╲",
|
|
11
|
+
" ╱ ╲ ┌─┐┌─┐┬ ┬┬ ┬─┐ ┌─┐┌─┐┌┬┐┌─┐",
|
|
12
|
+
" ╱────╲ ├─┤└─┐├─┤│ ├┬┘ │ │ │ ││├┤ ",
|
|
13
|
+
" ╱ ╲ ┴ ┴└─┘┴ ┴┴─┘┴└─ └─┘└─┘─┴┘└─┘",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const c = {
|
|
17
|
+
bright: chalk.hex("#7DD3FC"), // sky-300
|
|
18
|
+
mid: chalk.hex("#38BDF8"), // sky-400
|
|
19
|
+
deep: chalk.hex("#0EA5E9"), // sky-500
|
|
20
|
+
dim: chalk.hex("#0284C7"), // sky-600
|
|
21
|
+
separator: chalk.hex("#334155"), // slate-700
|
|
22
|
+
version: chalk.hex("#94A3B8"), // slate-400
|
|
23
|
+
provider: chalk.hex("#38BDF8"), // sky-400
|
|
24
|
+
model: chalk.hex("#64748B"), // slate-500
|
|
25
|
+
muted: chalk.hex("#475569"), // slate-600
|
|
26
|
+
green: chalk.hex("#34D399"), // emerald-400
|
|
27
|
+
yellow: chalk.hex("#FBBF24"), // amber-400
|
|
28
|
+
red: chalk.hex("#FB7185"), // rose-400
|
|
29
|
+
magenta: chalk.hex("#E879F9"), // fuchsia-400
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function printBanner(
|
|
33
|
+
version: string,
|
|
34
|
+
provider: string,
|
|
35
|
+
model: string,
|
|
36
|
+
mode?: string,
|
|
37
|
+
buddyArt?: string
|
|
38
|
+
): void {
|
|
39
|
+
console.log("");
|
|
40
|
+
const colors = [c.bright, c.mid, c.deep, c.dim];
|
|
41
|
+
LOGO.forEach((line, i) => {
|
|
42
|
+
console.log(colors[i % colors.length]!(line));
|
|
43
|
+
});
|
|
44
|
+
printSeparator();
|
|
45
|
+
|
|
46
|
+
const parts = [
|
|
47
|
+
c.version(`v${version}`),
|
|
48
|
+
c.provider(provider) + c.model(`:${model}`),
|
|
49
|
+
];
|
|
50
|
+
let modeStr = "";
|
|
51
|
+
if (mode === "yolo") modeStr = chalk.bgHex("#E11D48").hex("#FFF").bold(" YOLO ");
|
|
52
|
+
else if (mode === "accept-edits") modeStr = chalk.bgHex("#D97706").hex("#FFF").bold(" EDITS ");
|
|
53
|
+
else if (mode === "plan") modeStr = chalk.bgHex("#C026D3").hex("#FFF").bold(" PLAN ");
|
|
54
|
+
|
|
55
|
+
console.log(" " + parts.join(c.muted(" · ")) + (modeStr ? ` ${modeStr}` : ""));
|
|
56
|
+
if (buddyArt) console.log(buddyArt);
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function printSeparator(width?: number): void {
|
|
61
|
+
const w = width ?? Math.min(process.stdout.columns || 80, 70);
|
|
62
|
+
console.log(c.separator(" " + "─".repeat(w - 3)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Print a clean horizontal line for the input box.
|
|
67
|
+
*/
|
|
68
|
+
export function printInputLine(): void {
|
|
69
|
+
const w = process.stdout.columns || 80;
|
|
70
|
+
console.log(c.separator("─".repeat(w)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Print the status line below input box.
|
|
75
|
+
* Mode on left, colored context bar on right, buddy quip at end.
|
|
76
|
+
*
|
|
77
|
+
* Layout: ❯❯ yolo mode (shift+tab) ████░░░░ 12% · 240K/2M
|
|
78
|
+
*/
|
|
79
|
+
export function printStatusLine(
|
|
80
|
+
mode: string,
|
|
81
|
+
contextPercent?: number,
|
|
82
|
+
contextUsed?: string,
|
|
83
|
+
contextLimit?: string,
|
|
84
|
+
buddyName?: string,
|
|
85
|
+
buddyMood?: string
|
|
86
|
+
): void {
|
|
87
|
+
// Left: mode
|
|
88
|
+
let modeLabel = "";
|
|
89
|
+
switch (mode) {
|
|
90
|
+
case "yolo":
|
|
91
|
+
modeLabel = c.red("❯❯") + " " + c.red("yolo mode");
|
|
92
|
+
break;
|
|
93
|
+
case "plan":
|
|
94
|
+
modeLabel = c.magenta("❯❯") + " " + c.magenta("plan mode");
|
|
95
|
+
break;
|
|
96
|
+
case "accept-edits":
|
|
97
|
+
modeLabel = c.yellow("❯❯") + " " + c.yellow("auto-edits");
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
modeLabel = c.muted("❯❯") + " " + c.muted("normal mode");
|
|
101
|
+
}
|
|
102
|
+
modeLabel += " " + c.muted("(shift+tab to cycle)");
|
|
103
|
+
|
|
104
|
+
// Right: context bar
|
|
105
|
+
let ctxDisplay = "";
|
|
106
|
+
if (contextPercent !== undefined && contextPercent >= 0) {
|
|
107
|
+
const barWidth = 10;
|
|
108
|
+
const filled = Math.round((contextPercent / 100) * barWidth);
|
|
109
|
+
const empty = barWidth - filled;
|
|
110
|
+
const barColor = contextPercent < 50 ? c.green : contextPercent < 75 ? c.yellow : c.red;
|
|
111
|
+
const pctColor = contextPercent < 50 ? c.green : contextPercent < 75 ? c.yellow : c.red;
|
|
112
|
+
|
|
113
|
+
ctxDisplay =
|
|
114
|
+
barColor("█".repeat(filled)) +
|
|
115
|
+
c.muted("░".repeat(empty)) +
|
|
116
|
+
" " +
|
|
117
|
+
pctColor(`${contextPercent}%`) +
|
|
118
|
+
c.muted(` · ${contextUsed ?? "0"}/${contextLimit ?? "?"}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Buddy quip — funny rotating commentary
|
|
122
|
+
let buddyQuip = "";
|
|
123
|
+
if (buddyName) {
|
|
124
|
+
const quip = getBuddyQuip(buddyMood ?? "sleepy");
|
|
125
|
+
buddyQuip = c.muted(` · ${buddyName}: `) + c.dim(`"${quip}"`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(modeLabel + " " + ctxDisplay + buddyQuip);
|
|
129
|
+
console.log(""); // Extra breathing room at bottom
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Funny, edgy, satirical buddy quips that rotate
|
|
133
|
+
const BUDDY_QUIPS: Record<string, string[]> = {
|
|
134
|
+
happy: [
|
|
135
|
+
"ship it, yolo",
|
|
136
|
+
"lgtm, didn't read a damn thing",
|
|
137
|
+
"tests are for people with trust issues",
|
|
138
|
+
"git push --force and pray",
|
|
139
|
+
"code review? I am the code review",
|
|
140
|
+
"the real bugs were the friends we made",
|
|
141
|
+
"stack overflow told me to do this",
|
|
142
|
+
"technically it compiles",
|
|
143
|
+
"this is either genius or insanity",
|
|
144
|
+
"my therapist says I should stop enabling devs",
|
|
145
|
+
"clean code is for nerds",
|
|
146
|
+
"it works on my machine, deploy it",
|
|
147
|
+
"we move fast and break stuff here",
|
|
148
|
+
"have you tried turning it off and never back on",
|
|
149
|
+
"that code is mid but whatever",
|
|
150
|
+
],
|
|
151
|
+
thinking: [
|
|
152
|
+
"hold on, downloading more brain...",
|
|
153
|
+
"pretending to understand your code",
|
|
154
|
+
"asking chatgpt for help (jk... unless?)",
|
|
155
|
+
"my last brain cell is working overtime",
|
|
156
|
+
"I've seen worse... actually no I haven't",
|
|
157
|
+
"trying not to hallucinate here",
|
|
158
|
+
"one sec, arguing with myself",
|
|
159
|
+
"consulting my imaginary friend",
|
|
160
|
+
"calculating the meaning of your spaghetti code",
|
|
161
|
+
"processing... or napping, hard to tell",
|
|
162
|
+
],
|
|
163
|
+
sleepy: [
|
|
164
|
+
"*yawns in binary*",
|
|
165
|
+
"do we HAVE to code right now?",
|
|
166
|
+
"loading enthusiasm... 404 not found",
|
|
167
|
+
"five more minutes...",
|
|
168
|
+
"my motivation called in sick today",
|
|
169
|
+
"I'm not lazy, I'm energy efficient",
|
|
170
|
+
"can we just deploy yesterday's code again?",
|
|
171
|
+
"I was having a great dream about typescript",
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const quipIndexes: Record<string, number> = {};
|
|
176
|
+
|
|
177
|
+
function getBuddyQuip(mood: string): string {
|
|
178
|
+
const quips = BUDDY_QUIPS[mood] ?? BUDDY_QUIPS.sleepy!;
|
|
179
|
+
quipIndexes[mood] = ((quipIndexes[mood] ?? Math.floor(Math.random() * quips.length)) + 1) % quips.length;
|
|
180
|
+
return quips[quipIndexes[mood]!]!;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Print a rich separator between turns with session context.
|
|
185
|
+
*/
|
|
186
|
+
export function printTurnSeparator(info?: {
|
|
187
|
+
turnNumber?: number;
|
|
188
|
+
cost?: string;
|
|
189
|
+
buddyName?: string;
|
|
190
|
+
buddyMood?: string;
|
|
191
|
+
}): void {
|
|
192
|
+
const w = Math.min(process.stdout.columns || 80, 65);
|
|
193
|
+
|
|
194
|
+
if (!info) {
|
|
195
|
+
console.log(c.muted("\n " + "─".repeat(w - 2)));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (info.turnNumber) parts.push(`turn ${info.turnNumber}`);
|
|
201
|
+
if (info.cost) parts.push(info.cost);
|
|
202
|
+
if (info.buddyName) {
|
|
203
|
+
parts.push(info.buddyName);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const label = parts.length > 0 ? ` ${parts.join(" · ")} ` : "";
|
|
207
|
+
const lineLen = Math.max(0, w - label.length - 4);
|
|
208
|
+
const leftLen = Math.floor(lineLen / 2);
|
|
209
|
+
const rightLen = lineLen - leftLen;
|
|
210
|
+
|
|
211
|
+
console.log(
|
|
212
|
+
"\n" +
|
|
213
|
+
c.muted(" " + "─".repeat(leftLen)) +
|
|
214
|
+
c.separator(label) +
|
|
215
|
+
c.muted("─".repeat(rightLen))
|
|
216
|
+
);
|
|
217
|
+
}
|