feat: complete architecture rewrite with Bun, OpenTUI, bash-only tools, and X Search API
Major rewrite of the Grok CLI agent: - Runtime: Bun-first (removed Node.js/tsx fallbacks) - UI: Replaced Ink with OpenTUI (@opentui/react) terminal framework - Tools: Simplified to bash-only (removed text-editor, search, morph, todo, confirmation tools) - Search: Integrated X Search API and Web Search via xAI Responses API - Models: Updated to all latest Grok models (grok-4, grok-4.20-beta, grok-4-1-fast, grok-4-fast, grok-3-mini) - Agent: Streamlined agent loop with async generator streaming - Client: Dual API support (Chat Completions for tool calling, Responses API for search) - Removed MCP integration, confirmation service, token counter, Morph editor - Updated tsconfig for OpenTUI React JSX - Clean 10-file architecture in src/ Co-authored-by: Ismail Pelaseyed <homanp@users.noreply.github.com>
This commit is contained in:
1 parent
e763e2f472
commit
ccfd3bb6fc
52 files changed
+1349
-8815
No files matched your search
@@ -1,149 +0,0 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ConfirmationOptions {
|
||||
operation: string;
|
||||
filename: string;
|
||||
showVSCodeOpen?: boolean;
|
||||
content?: string; // Content to show in confirmation dialog
|
||||
}
|
||||
|
||||
export interface ConfirmationResult {
|
||||
confirmed: boolean;
|
||||
dontAskAgain?: boolean;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export class ConfirmationService extends EventEmitter {
|
||||
private static instance: ConfirmationService;
|
||||
private skipConfirmationThisSession = false;
|
||||
private pendingConfirmation: Promise<ConfirmationResult> | null = null;
|
||||
private resolveConfirmation: ((result: ConfirmationResult) => void) | null =
|
||||
null;
|
||||
|
||||
// Session flags for different operation types
|
||||
private sessionFlags = {
|
||||
fileOperations: false,
|
||||
bashCommands: false,
|
||||
allOperations: false,
|
||||
};
|
||||
|
||||
static getInstance(): ConfirmationService {
|
||||
if (!ConfirmationService.instance) {
|
||||
ConfirmationService.instance = new ConfirmationService();
|
||||
}
|
||||
return ConfirmationService.instance;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async requestConfirmation(
|
||||
options: ConfirmationOptions,
|
||||
operationType: "file" | "bash" = "file"
|
||||
): Promise<ConfirmationResult> {
|
||||
// Check session flags
|
||||
if (
|
||||
this.sessionFlags.allOperations ||
|
||||
(operationType === "file" && this.sessionFlags.fileOperations) ||
|
||||
(operationType === "bash" && this.sessionFlags.bashCommands)
|
||||
) {
|
||||
return { confirmed: true };
|
||||
}
|
||||
|
||||
// If VS Code should be opened, try to open it
|
||||
if (options.showVSCodeOpen) {
|
||||
try {
|
||||
await this.openInVSCode(options.filename);
|
||||
} catch (error) {
|
||||
// If VS Code opening fails, continue without it
|
||||
options.showVSCodeOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a promise that will be resolved by the UI component
|
||||
this.pendingConfirmation = new Promise<ConfirmationResult>((resolve) => {
|
||||
this.resolveConfirmation = resolve;
|
||||
});
|
||||
|
||||
// Emit custom event that the UI can listen to (using setImmediate to ensure the UI updates)
|
||||
setImmediate(() => {
|
||||
this.emit("confirmation-requested", options);
|
||||
});
|
||||
|
||||
const result = await this.pendingConfirmation;
|
||||
|
||||
if (result.dontAskAgain) {
|
||||
// Set the appropriate session flag based on operation type
|
||||
if (operationType === "file") {
|
||||
this.sessionFlags.fileOperations = true;
|
||||
} else if (operationType === "bash") {
|
||||
this.sessionFlags.bashCommands = true;
|
||||
}
|
||||
// Could also set allOperations for global skip
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
confirmOperation(confirmed: boolean, dontAskAgain?: boolean): void {
|
||||
if (this.resolveConfirmation) {
|
||||
this.resolveConfirmation({ confirmed, dontAskAgain });
|
||||
this.resolveConfirmation = null;
|
||||
this.pendingConfirmation = null;
|
||||
}
|
||||
}
|
||||
|
||||
rejectOperation(feedback?: string): void {
|
||||
if (this.resolveConfirmation) {
|
||||
this.resolveConfirmation({ confirmed: false, feedback });
|
||||
this.resolveConfirmation = null;
|
||||
this.pendingConfirmation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async openInVSCode(filename: string): Promise<void> {
|
||||
// Try different VS Code commands
|
||||
const commands = ["code", "code-insiders", "codium"];
|
||||
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
await execAsync(`which ${cmd}`);
|
||||
await execAsync(`${cmd} "${filename}"`);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Continue to next command
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("VS Code not found");
|
||||
}
|
||||
|
||||
isPending(): boolean {
|
||||
return this.pendingConfirmation !== null;
|
||||
}
|
||||
|
||||
resetSession(): void {
|
||||
this.sessionFlags = {
|
||||
fileOperations: false,
|
||||
bashCommands: false,
|
||||
allOperations: false,
|
||||
};
|
||||
}
|
||||
|
||||
getSessionFlags() {
|
||||
return { ...this.sessionFlags };
|
||||
}
|
||||
|
||||
setSessionFlag(
|
||||
flagType: "fileOperations" | "bashCommands" | "allOperations",
|
||||
value: boolean
|
||||
) {
|
||||
this.sessionFlags[flagType] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export function loadCustomInstructions(workingDirectory: string = process.cwd()): string | null {
|
||||
try {
|
||||
let instructionsPath = path.join(workingDirectory, '.grok', 'GROK.md');
|
||||
|
||||
if (fs.existsSync(instructionsPath)) {
|
||||
const customInstructions = fs.readFileSync(instructionsPath, 'utf-8');
|
||||
return customInstructions.trim();
|
||||
}
|
||||
|
||||
instructionsPath = path.join(os.homedir(), '.grok', 'GROK.md');
|
||||
|
||||
if (fs.existsSync(instructionsPath)) {
|
||||
const customInstructions = fs.readFileSync(instructionsPath, 'utf-8');
|
||||
return customInstructions.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load custom instructions:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
export function loadCustomInstructions(): string | null {
|
||||
const candidates = [
|
||||
path.join(process.cwd(), ".grok", "GROK.md"),
|
||||
path.join(os.homedir(), ".grok", "GROK.md"),
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
return fs.readFileSync(p, "utf-8").trim();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js';
|
||||
|
||||
export interface ModelOption {
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type ModelConfig = string;
|
||||
|
||||
// Re-export interfaces for backward compatibility
|
||||
export { UserSettings, ProjectSettings };
|
||||
|
||||
/**
|
||||
* Get the effective current model
|
||||
* Priority: project current model > user default model > system default
|
||||
*/
|
||||
export function getCurrentModel(): string {
|
||||
const manager = getSettingsManager();
|
||||
return manager.getCurrentModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model configuration
|
||||
* Priority: user-settings.json models > default hardcoded
|
||||
*/
|
||||
export function loadModelConfig(): ModelOption[] {
|
||||
const manager = getSettingsManager();
|
||||
const models = manager.getAvailableModels();
|
||||
|
||||
return models.map(model => ({
|
||||
model: model.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default models list
|
||||
*/
|
||||
export function getDefaultModels(): string[] {
|
||||
const manager = getSettingsManager();
|
||||
return manager.getAvailableModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current model in project settings
|
||||
*/
|
||||
export function updateCurrentModel(modelName: string): void {
|
||||
const manager = getSettingsManager();
|
||||
manager.setCurrentModel(modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's default model preference
|
||||
*/
|
||||
export function updateDefaultModel(modelName: string): void {
|
||||
const manager = getSettingsManager();
|
||||
manager.updateUserSetting('defaultModel', modelName);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
/**
|
||||
* Current settings version - increment this when adding new models or changing settings structure
|
||||
* This triggers automatic migration for existing users
|
||||
*/
|
||||
const SETTINGS_VERSION = 2;
|
||||
|
||||
/**
|
||||
* User-level settings stored in ~/.grok/user-settings.json
|
||||
* These are global settings that apply across all projects
|
||||
*/
|
||||
export interface UserSettings {
|
||||
apiKey?: string; // Grok API key
|
||||
baseURL?: string; // API base URL
|
||||
defaultModel?: string; // User's preferred default model
|
||||
models?: string[]; // Available models list
|
||||
settingsVersion?: number; // Version for migration tracking
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-level settings stored in .grok/settings.json
|
||||
* These are project-specific settings
|
||||
*/
|
||||
export interface ProjectSettings {
|
||||
model?: string; // Current model for this project
|
||||
mcpServers?: Record<string, any>; // MCP server configurations
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for user settings
|
||||
*/
|
||||
const DEFAULT_USER_SETTINGS: Partial<UserSettings> = {
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
defaultModel: "grok-code-fast-1",
|
||||
models: [
|
||||
// Grok 4.1 Fast models (2M context, latest - November 2025)
|
||||
"grok-4-1-fast-reasoning",
|
||||
"grok-4-1-fast-non-reasoning",
|
||||
// Grok 4 Fast models (2M context)
|
||||
"grok-4-fast-reasoning",
|
||||
"grok-4-fast-non-reasoning",
|
||||
// Grok 4 flagship (256K context)
|
||||
"grok-4",
|
||||
"grok-4-latest",
|
||||
// Grok Code (optimized for coding, 256K context)
|
||||
"grok-code-fast-1",
|
||||
// Grok 3 models (131K context)
|
||||
"grok-3",
|
||||
"grok-3-latest",
|
||||
"grok-3-fast",
|
||||
"grok-3-mini",
|
||||
"grok-3-mini-fast",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Default values for project settings
|
||||
*/
|
||||
const DEFAULT_PROJECT_SETTINGS: Partial<ProjectSettings> = {
|
||||
model: "grok-code-fast-1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified settings manager that handles both user-level and project-level settings
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private static instance: SettingsManager;
|
||||
|
||||
private userSettingsPath: string;
|
||||
private projectSettingsPath: string;
|
||||
|
||||
private constructor() {
|
||||
// User settings path: ~/.grok/user-settings.json
|
||||
this.userSettingsPath = path.join(
|
||||
os.homedir(),
|
||||
".grok",
|
||||
"user-settings.json"
|
||||
);
|
||||
|
||||
// Project settings path: .grok/settings.json (in current working directory)
|
||||
this.projectSettingsPath = path.join(
|
||||
process.cwd(),
|
||||
".grok",
|
||||
"settings.json"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): SettingsManager {
|
||||
if (!SettingsManager.instance) {
|
||||
SettingsManager.instance = new SettingsManager();
|
||||
}
|
||||
return SettingsManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists for a given file path
|
||||
*/
|
||||
private ensureDirectoryExists(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user settings from ~/.grok/user-settings.json
|
||||
*/
|
||||
public loadUserSettings(): UserSettings {
|
||||
try {
|
||||
if (!fs.existsSync(this.userSettingsPath)) {
|
||||
// Create default user settings if file doesn't exist
|
||||
const newSettings = { ...DEFAULT_USER_SETTINGS, settingsVersion: SETTINGS_VERSION };
|
||||
this.saveUserSettings(newSettings);
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.userSettingsPath, "utf-8");
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
// Check if migration is needed
|
||||
const currentVersion = settings.settingsVersion || 1;
|
||||
if (currentVersion < SETTINGS_VERSION) {
|
||||
const migratedSettings = this.migrateSettings(settings, currentVersion);
|
||||
this.saveUserSettings(migratedSettings);
|
||||
return migratedSettings;
|
||||
}
|
||||
|
||||
// Merge with defaults to ensure all required fields exist
|
||||
return { ...DEFAULT_USER_SETTINGS, ...settings };
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to load user settings:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
return { ...DEFAULT_USER_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate settings from an older version to the current version
|
||||
*/
|
||||
private migrateSettings(settings: UserSettings, fromVersion: number): UserSettings {
|
||||
let migrated = { ...settings };
|
||||
|
||||
// Migration from version 1 to 2: Add new Grok 4.1 and Grok 4 Fast models
|
||||
if (fromVersion < 2) {
|
||||
const defaultModels = DEFAULT_USER_SETTINGS.models || [];
|
||||
const existingModels = new Set(migrated.models || []);
|
||||
|
||||
// Add any new models that don't exist in user's current list
|
||||
const newModels = defaultModels.filter(model => !existingModels.has(model));
|
||||
|
||||
// Prepend new models to the list (newest models first)
|
||||
migrated.models = [...newModels, ...(migrated.models || [])];
|
||||
}
|
||||
|
||||
// Add future migrations here:
|
||||
// if (fromVersion < 3) { ... }
|
||||
|
||||
migrated.settingsVersion = SETTINGS_VERSION;
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user settings to ~/.grok/user-settings.json
|
||||
*/
|
||||
public saveUserSettings(settings: Partial<UserSettings>): void {
|
||||
try {
|
||||
this.ensureDirectoryExists(this.userSettingsPath);
|
||||
|
||||
// Read existing settings directly to avoid recursion
|
||||
let existingSettings: UserSettings = { ...DEFAULT_USER_SETTINGS };
|
||||
if (fs.existsSync(this.userSettingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(this.userSettingsPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
existingSettings = { ...DEFAULT_USER_SETTINGS, ...parsed };
|
||||
} catch (error) {
|
||||
// If file is corrupted, use defaults
|
||||
console.warn("Corrupted user settings file, using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSettings = { ...existingSettings, ...settings };
|
||||
|
||||
fs.writeFileSync(
|
||||
this.userSettingsPath,
|
||||
JSON.stringify(mergedSettings, null, 2),
|
||||
{ mode: 0o600 } // Secure permissions for API key
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save user settings:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific user setting
|
||||
*/
|
||||
public updateUserSetting<K extends keyof UserSettings>(
|
||||
key: K,
|
||||
value: UserSettings[K]
|
||||
): void {
|
||||
const settings = { [key]: value } as Partial<UserSettings>;
|
||||
this.saveUserSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user setting
|
||||
*/
|
||||
public getUserSetting<K extends keyof UserSettings>(key: K): UserSettings[K] {
|
||||
const settings = this.loadUserSettings();
|
||||
return settings[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project settings from .grok/settings.json
|
||||
*/
|
||||
public loadProjectSettings(): ProjectSettings {
|
||||
try {
|
||||
if (!fs.existsSync(this.projectSettingsPath)) {
|
||||
// Create default project settings if file doesn't exist
|
||||
this.saveProjectSettings(DEFAULT_PROJECT_SETTINGS);
|
||||
return { ...DEFAULT_PROJECT_SETTINGS };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.projectSettingsPath, "utf-8");
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
// Merge with defaults
|
||||
return { ...DEFAULT_PROJECT_SETTINGS, ...settings };
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to load project settings:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
return { ...DEFAULT_PROJECT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save project settings to .grok/settings.json
|
||||
*/
|
||||
public saveProjectSettings(settings: Partial<ProjectSettings>): void {
|
||||
try {
|
||||
this.ensureDirectoryExists(this.projectSettingsPath);
|
||||
|
||||
// Read existing settings directly to avoid recursion
|
||||
let existingSettings: ProjectSettings = { ...DEFAULT_PROJECT_SETTINGS };
|
||||
if (fs.existsSync(this.projectSettingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(this.projectSettingsPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
existingSettings = { ...DEFAULT_PROJECT_SETTINGS, ...parsed };
|
||||
} catch (error) {
|
||||
// If file is corrupted, use defaults
|
||||
console.warn("Corrupted project settings file, using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSettings = { ...existingSettings, ...settings };
|
||||
|
||||
fs.writeFileSync(
|
||||
this.projectSettingsPath,
|
||||
JSON.stringify(mergedSettings, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save project settings:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific project setting
|
||||
*/
|
||||
public updateProjectSetting<K extends keyof ProjectSettings>(
|
||||
key: K,
|
||||
value: ProjectSettings[K]
|
||||
): void {
|
||||
const settings = { [key]: value } as Partial<ProjectSettings>;
|
||||
this.saveProjectSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific project setting
|
||||
*/
|
||||
public getProjectSetting<K extends keyof ProjectSettings>(
|
||||
key: K
|
||||
): ProjectSettings[K] {
|
||||
const settings = this.loadProjectSettings();
|
||||
return settings[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current model with proper fallback logic:
|
||||
* 1. Project-specific model setting
|
||||
* 2. User's default model
|
||||
* 3. System default
|
||||
*/
|
||||
public getCurrentModel(): string {
|
||||
const projectModel = this.getProjectSetting("model");
|
||||
if (projectModel) {
|
||||
return projectModel;
|
||||
}
|
||||
|
||||
const userDefaultModel = this.getUserSetting("defaultModel");
|
||||
if (userDefaultModel) {
|
||||
return userDefaultModel;
|
||||
}
|
||||
|
||||
return DEFAULT_PROJECT_SETTINGS.model || "grok-code-fast-1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current model for the project
|
||||
*/
|
||||
public setCurrentModel(model: string): void {
|
||||
this.updateProjectSetting("model", model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models list from user settings
|
||||
*/
|
||||
public getAvailableModels(): string[] {
|
||||
const models = this.getUserSetting("models");
|
||||
return models || DEFAULT_USER_SETTINGS.models || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from user settings or environment
|
||||
*/
|
||||
public getApiKey(): string | undefined {
|
||||
// First check environment variable
|
||||
const envApiKey = process.env.GROK_API_KEY;
|
||||
if (envApiKey) {
|
||||
return envApiKey;
|
||||
}
|
||||
|
||||
// Then check user settings
|
||||
return this.getUserSetting("apiKey");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL from user settings or environment
|
||||
*/
|
||||
public getBaseURL(): string {
|
||||
// First check environment variable
|
||||
const envBaseURL = process.env.GROK_BASE_URL;
|
||||
if (envBaseURL) {
|
||||
return envBaseURL;
|
||||
}
|
||||
|
||||
// Then check user settings
|
||||
const userBaseURL = this.getUserSetting("baseURL");
|
||||
return (
|
||||
userBaseURL || DEFAULT_USER_SETTINGS.baseURL || "https://api.x.ai/v1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the singleton instance
|
||||
*/
|
||||
export function getSettingsManager(): SettingsManager {
|
||||
return SettingsManager.getInstance();
|
||||
}
|
||||
+84
-3
@@ -1,4 +1,85 @@
|
||||
// This file is kept for potential future convenience functions
|
||||
// All settings management should use getSettingsManager() from './settings-manager.js'
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { DEFAULT_MODEL, getModelIds } from "../grok/models.js";
|
||||
|
||||
export { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js';
|
||||
export interface UserSettings {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
defaultModel?: string;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
const USER_DIR = path.join(os.homedir(), ".grok");
|
||||
const USER_SETTINGS_PATH = path.join(USER_DIR, "user-settings.json");
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
function readJson<T>(filePath: string): T | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, data: unknown): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function loadUserSettings(): UserSettings {
|
||||
const defaults: UserSettings = {
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
models: getModelIds(),
|
||||
};
|
||||
const saved = readJson<UserSettings>(USER_SETTINGS_PATH);
|
||||
return { ...defaults, ...saved };
|
||||
}
|
||||
|
||||
export function saveUserSettings(partial: Partial<UserSettings>): void {
|
||||
const current = loadUserSettings();
|
||||
writeJson(USER_SETTINGS_PATH, { ...current, ...partial });
|
||||
}
|
||||
|
||||
export function loadProjectSettings(): ProjectSettings {
|
||||
const projectPath = path.join(process.cwd(), ".grok", "settings.json");
|
||||
return readJson<ProjectSettings>(projectPath) || {};
|
||||
}
|
||||
|
||||
export function saveProjectSettings(partial: Partial<ProjectSettings>): void {
|
||||
const projectPath = path.join(process.cwd(), ".grok", "settings.json");
|
||||
const current = loadProjectSettings();
|
||||
writeJson(projectPath, { ...current, ...partial });
|
||||
}
|
||||
|
||||
export function getApiKey(): string | undefined {
|
||||
return process.env.GROK_API_KEY || loadUserSettings().apiKey;
|
||||
}
|
||||
|
||||
export function getBaseURL(): string {
|
||||
return process.env.GROK_BASE_URL || loadUserSettings().baseURL || "https://api.x.ai/v1";
|
||||
}
|
||||
|
||||
export function getCurrentModel(): string {
|
||||
if (process.env.GROK_MODEL) return process.env.GROK_MODEL;
|
||||
const project = loadProjectSettings();
|
||||
if (project.model) return project.model;
|
||||
const user = loadUserSettings();
|
||||
return user.defaultModel || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
export function getAvailableModels(): string[] {
|
||||
const user = loadUserSettings();
|
||||
return user.models || getModelIds();
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Text manipulation utilities for terminal input handling
|
||||
* Inspired by Gemini CLI's text processing capabilities
|
||||
*/
|
||||
|
||||
export interface TextPosition {
|
||||
index: number;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface TextSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is a word boundary
|
||||
*/
|
||||
export function isWordBoundary(char: string | undefined): boolean {
|
||||
if (!char) return true;
|
||||
return /\s/.test(char) || /[^\w]/.test(char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the start of the current word at the given position
|
||||
*/
|
||||
export function findWordStart(text: string, position: number): number {
|
||||
if (position <= 0) return 0;
|
||||
|
||||
let pos = position - 1;
|
||||
while (pos > 0 && !isWordBoundary(text[pos])) {
|
||||
pos--;
|
||||
}
|
||||
|
||||
// If we stopped at a word boundary, move forward to the actual word start
|
||||
if (pos > 0 && isWordBoundary(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of the current word at the given position
|
||||
*/
|
||||
export function findWordEnd(text: string, position: number): number {
|
||||
if (position >= text.length) return text.length;
|
||||
|
||||
let pos = position;
|
||||
while (pos < text.length && !isWordBoundary(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the previous word boundary
|
||||
*/
|
||||
export function moveToPreviousWord(text: string, position: number): number {
|
||||
if (position <= 0) return 0;
|
||||
|
||||
let pos = position - 1;
|
||||
|
||||
// Skip whitespace
|
||||
while (pos > 0 && isWordBoundary(text[pos])) {
|
||||
pos--;
|
||||
}
|
||||
|
||||
// Find start of the word
|
||||
while (pos > 0 && !isWordBoundary(text[pos - 1])) {
|
||||
pos--;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the next word boundary
|
||||
*/
|
||||
export function moveToNextWord(text: string, position: number): number {
|
||||
if (position >= text.length) return text.length;
|
||||
|
||||
let pos = position;
|
||||
|
||||
// Skip current word
|
||||
while (pos < text.length && !isWordBoundary(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (pos < text.length && isWordBoundary(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the word before the cursor
|
||||
*/
|
||||
export function deleteWordBefore(text: string, position: number): { text: string; position: number } {
|
||||
const wordStart = moveToPreviousWord(text, position);
|
||||
const newText = text.slice(0, wordStart) + text.slice(position);
|
||||
|
||||
return {
|
||||
text: newText,
|
||||
position: wordStart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the word after the cursor
|
||||
*/
|
||||
export function deleteWordAfter(text: string, position: number): { text: string; position: number } {
|
||||
const wordEnd = moveToNextWord(text, position);
|
||||
const newText = text.slice(0, position) + text.slice(wordEnd);
|
||||
|
||||
return {
|
||||
text: newText,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current line and column from text position
|
||||
*/
|
||||
export function getTextPosition(text: string, index: number): TextPosition {
|
||||
const lines = text.slice(0, index).split('\n');
|
||||
return {
|
||||
index,
|
||||
line: lines.length - 1,
|
||||
column: lines[lines.length - 1].length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the beginning of the current line
|
||||
*/
|
||||
export function moveToLineStart(text: string, position: number): number {
|
||||
const beforeCursor = text.slice(0, position);
|
||||
const lastNewlineIndex = beforeCursor.lastIndexOf('\n');
|
||||
return lastNewlineIndex === -1 ? 0 : lastNewlineIndex + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the end of the current line
|
||||
*/
|
||||
export function moveToLineEnd(text: string, position: number): number {
|
||||
const afterCursor = text.slice(position);
|
||||
const nextNewlineIndex = afterCursor.indexOf('\n');
|
||||
return nextNewlineIndex === -1 ? text.length : position + nextNewlineIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle proper Unicode-aware character deletion
|
||||
*/
|
||||
export function deleteCharBefore(text: string, position: number): { text: string; position: number } {
|
||||
if (position <= 0) {
|
||||
return { text, position };
|
||||
}
|
||||
|
||||
// Handle surrogate pairs and combining characters
|
||||
let deleteCount = 1;
|
||||
const charBefore = text.charAt(position - 1);
|
||||
|
||||
// Check for high surrogate (first part of surrogate pair)
|
||||
if (position >= 2) {
|
||||
const charBeforeBefore = text.charAt(position - 2);
|
||||
if (charBeforeBefore >= '\uD800' && charBeforeBefore <= '\uDBFF' &&
|
||||
charBefore >= '\uDC00' && charBefore <= '\uDFFF') {
|
||||
deleteCount = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const newText = text.slice(0, position - deleteCount) + text.slice(position);
|
||||
return {
|
||||
text: newText,
|
||||
position: position - deleteCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle proper Unicode-aware character deletion forward
|
||||
*/
|
||||
export function deleteCharAfter(text: string, position: number): { text: string; position: number } {
|
||||
if (position >= text.length) {
|
||||
return { text, position };
|
||||
}
|
||||
|
||||
// Handle surrogate pairs and combining characters
|
||||
let deleteCount = 1;
|
||||
const charAfter = text.charAt(position);
|
||||
|
||||
// Check for high surrogate (first part of surrogate pair)
|
||||
if (position + 1 < text.length) {
|
||||
const charAfterAfter = text.charAt(position + 1);
|
||||
if (charAfter >= '\uD800' && charAfter <= '\uDBFF' &&
|
||||
charAfterAfter >= '\uDC00' && charAfterAfter <= '\uDFFF') {
|
||||
deleteCount = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const newText = text.slice(0, position) + text.slice(position + deleteCount);
|
||||
return {
|
||||
text: newText,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at the given position with proper Unicode handling
|
||||
*/
|
||||
export function insertText(text: string, position: number, insert: string): { text: string; position: number } {
|
||||
const newText = text.slice(0, position) + insert + text.slice(position);
|
||||
return {
|
||||
text: newText,
|
||||
position: position + insert.length,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { get_encoding, encoding_for_model, Tiktoken } from 'tiktoken';
|
||||
|
||||
export class TokenCounter {
|
||||
private encoder: Tiktoken;
|
||||
|
||||
constructor(model: string = 'gpt-4') {
|
||||
try {
|
||||
// Try to get encoding for specific model
|
||||
this.encoder = encoding_for_model(model as any);
|
||||
} catch {
|
||||
// Fallback to cl100k_base (used by GPT-4 and most modern models)
|
||||
this.encoder = get_encoding('cl100k_base');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in a string
|
||||
*/
|
||||
countTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return this.encoder.encode(text).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in messages array (for chat completions)
|
||||
*/
|
||||
countMessageTokens(messages: Array<{ role: string; content: string | null; [key: string]: any }>): number {
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
// Every message follows <|start|>{role/name}\n{content}<|end|\>\n
|
||||
totalTokens += 3; // Base tokens per message
|
||||
|
||||
if (message.content && typeof message.content === 'string') {
|
||||
totalTokens += this.countTokens(message.content);
|
||||
}
|
||||
|
||||
if (message.role) {
|
||||
totalTokens += this.countTokens(message.role);
|
||||
}
|
||||
|
||||
// Add extra tokens for tool calls if present
|
||||
if (message.tool_calls) {
|
||||
totalTokens += this.countTokens(JSON.stringify(message.tool_calls));
|
||||
}
|
||||
}
|
||||
|
||||
totalTokens += 3; // Every reply is primed with <|start|>assistant<|message|>
|
||||
|
||||
return totalTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for streaming content
|
||||
* This is an approximation since we don't have the full response yet
|
||||
*/
|
||||
estimateStreamingTokens(accumulatedContent: string): number {
|
||||
return this.countTokens(accumulatedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.encoder.free();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token count for display (e.g., 1.2k for 1200)
|
||||
*/
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
if (count < 1_000_000) {
|
||||
const k = count / 1000;
|
||||
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
|
||||
const m = count / 1_000_000;
|
||||
return m % 1 === 0 ? `${m}m` : `${m.toFixed(1)}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a token counter instance
|
||||
*/
|
||||
export function createTokenCounter(model?: string): TokenCounter {
|
||||
return new TokenCounter(model);
|
||||
}
|
||||
Reference in new issue
Block a user