mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 06:00:26 +00:00
Initial monorepo setup with npm workspaces and dual TypeScript configuration
- Set up npm workspaces for three packages: pi-tui, pi-agent, and pi (pods) - Implemented dual TypeScript configuration: - Root tsconfig.json with path mappings for development and type checking - Package-specific tsconfig.build.json for clean production builds - Configured lockstep versioning with sync script for inter-package dependencies - Added comprehensive documentation for development and publishing workflows - All packages at version 0.5.0 ready for npm publishing
This commit is contained in:
commit
a74c5da112
63 changed files with 14558 additions and 0 deletions
655
packages/tui/README.md
Normal file
655
packages/tui/README.md
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
# @mariozechner/pi-tui
|
||||
|
||||
Terminal UI framework with differential rendering for building interactive CLI applications.
|
||||
|
||||
## Features
|
||||
|
||||
- **Differential Rendering**: Only re-renders content that has changed for optimal performance
|
||||
- **Interactive Components**: Text editor, autocomplete, selection lists, and markdown rendering
|
||||
- **Composable Architecture**: Container-based component system with proper lifecycle management
|
||||
- **Text Editor Autocomplete System**: File completion and slash commands with provider interface
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { TUI, Container, TextComponent, TextEditor } from "@mariozechner/pi-tui";
|
||||
|
||||
// Create TUI manager
|
||||
const ui = new TUI();
|
||||
|
||||
// Create components
|
||||
const header = new TextComponent("🚀 My TUI App");
|
||||
const chatContainer = new Container();
|
||||
const editor = new TextEditor();
|
||||
|
||||
// Add components to UI
|
||||
ui.addChild(header);
|
||||
ui.addChild(chatContainer);
|
||||
ui.addChild(editor);
|
||||
|
||||
// Set focus to the editor
|
||||
ui.setFocus(editor);
|
||||
|
||||
// Handle editor submissions
|
||||
editor.onSubmit = (text: string) => {
|
||||
if (text.trim()) {
|
||||
const message = new TextComponent(`💬 ${text}`);
|
||||
chatContainer.addChild(message);
|
||||
ui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
// Start the UI
|
||||
ui.start();
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### TUI
|
||||
|
||||
Main TUI manager that handles rendering, input, and component coordination.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `addChild(component)` - Add a component to the TUI
|
||||
- `removeChild(component)` - Remove a component from the TUI
|
||||
- `setFocus(component)` - Set which component receives keyboard input
|
||||
- `start()` - Start the TUI (enables raw mode)
|
||||
- `stop()` - Stop the TUI (disables raw mode)
|
||||
- `requestRender()` - Request a re-render on next tick
|
||||
- `configureLogging(config)` - Configure debug logging
|
||||
- `cleanupSentinels()` - Remove placeholder components after removal operations
|
||||
- `findComponent(component)` - Check if a component exists in the hierarchy (private)
|
||||
- `findInContainer(container, component)` - Search for component in container (private)
|
||||
|
||||
### Container
|
||||
|
||||
Component that manages child components with differential rendering.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new Container(parentTui?: TUI | undefined)
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `addChild(component)` - Add a child component
|
||||
- `removeChild(component)` - Remove a child component
|
||||
- `getChild(index)` - Get a specific child component
|
||||
- `getChildCount()` - Get the number of child components
|
||||
- `clear()` - Remove all child components
|
||||
- `setParentTui(tui)` - Set the parent TUI reference
|
||||
- `cleanupSentinels()` - Clean up removed component placeholders
|
||||
- `render(width)` - Render all child components (returns ContainerRenderResult)
|
||||
|
||||
### TextEditor
|
||||
|
||||
Interactive multiline text editor with cursor support and comprehensive keyboard shortcuts.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new TextEditor(config?: TextEditorConfig)
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```typescript
|
||||
interface TextEditorConfig {
|
||||
// Configuration options for text editor
|
||||
}
|
||||
|
||||
editor.configure(config: Partial<TextEditorConfig>)
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `onSubmit?: (text: string) => void` - Callback when user presses Enter
|
||||
- `onChange?: (text: string) => void` - Callback when text content changes
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `getText()` - Get current text content
|
||||
- `setText(text)` - Set text content and move cursor to end
|
||||
- `setAutocompleteProvider(provider)` - Set autocomplete provider for Tab completion
|
||||
- `render(width)` - Render the editor with current state
|
||||
- `handleInput(data)` - Process keyboard input
|
||||
|
||||
**Keyboard Shortcuts:**
|
||||
|
||||
**Navigation:**
|
||||
|
||||
- `Arrow Keys` - Move cursor
|
||||
- `Home` / `Ctrl+A` - Move to start of line
|
||||
- `End` / `Ctrl+E` - Move to end of line
|
||||
|
||||
**Editing:**
|
||||
|
||||
- `Backspace` - Delete character before cursor
|
||||
- `Delete` / `Fn+Backspace` - Delete character at cursor
|
||||
- `Ctrl+K` - Delete current line
|
||||
- `Enter` - Submit text (calls onSubmit)
|
||||
- `Shift+Enter` / `Option+Enter` - Add new line
|
||||
- `Tab` - Trigger autocomplete
|
||||
|
||||
**Autocomplete (when active):**
|
||||
|
||||
- `Tab` - Apply selected completion
|
||||
- `Arrow Up/Down` - Navigate suggestions
|
||||
- `Escape` - Cancel autocomplete
|
||||
- `Enter` - Cancel autocomplete and submit
|
||||
|
||||
**Paste Detection:**
|
||||
|
||||
- Automatically handles multi-line paste
|
||||
- Converts tabs to 4 spaces
|
||||
- Filters non-printable characters
|
||||
|
||||
### TextComponent
|
||||
|
||||
Simple text component with automatic text wrapping and differential rendering.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new TextComponent(text: string, padding?: Padding)
|
||||
|
||||
interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `setText(text)` - Update the text content
|
||||
- `getText()` - Get current text content
|
||||
- `render(width)` - Render with word wrapping
|
||||
|
||||
**Features:**
|
||||
|
||||
- Automatic text wrapping to fit terminal width
|
||||
- Configurable padding on all sides
|
||||
- Preserves line breaks in source text
|
||||
- Uses differential rendering to avoid unnecessary updates
|
||||
|
||||
### MarkdownComponent
|
||||
|
||||
Renders markdown content with syntax highlighting and proper formatting.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new MarkdownComponent(text?: string)
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `setText(text)` - Update markdown content
|
||||
- `render(width)` - Render parsed markdown
|
||||
|
||||
**Features:**
|
||||
|
||||
- **Headings**: Styled with colors and formatting
|
||||
- **Code blocks**: Syntax highlighting with gray background
|
||||
- **Lists**: Bullet points (•) and numbered lists
|
||||
- **Emphasis**: **Bold** and _italic_ text
|
||||
- **Links**: Underlined with URL display
|
||||
- **Blockquotes**: Styled with left border
|
||||
- **Inline code**: Highlighted with background
|
||||
- **Horizontal rules**: Terminal-width separator lines
|
||||
- Differential rendering for performance
|
||||
|
||||
### SelectList
|
||||
|
||||
Interactive selection component for choosing from options.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new SelectList(items: SelectItem[], maxVisible?: number)
|
||||
|
||||
interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `onSelect?: (item: SelectItem) => void` - Called when item is selected
|
||||
- `onCancel?: () => void` - Called when selection is cancelled
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `setFilter(filter)` - Filter items by value
|
||||
- `getSelectedItem()` - Get currently selected item
|
||||
- `handleInput(keyData)` - Handle keyboard navigation
|
||||
- `render(width)` - Render the selection list
|
||||
|
||||
**Features:**
|
||||
|
||||
- Keyboard navigation (arrow keys, Enter)
|
||||
- Search/filter functionality
|
||||
- Scrolling for long lists
|
||||
- Custom option rendering with descriptions
|
||||
- Visual selection indicator (→)
|
||||
- Scroll position indicator
|
||||
|
||||
### Autocomplete System
|
||||
|
||||
Comprehensive autocomplete system supporting slash commands and file paths.
|
||||
|
||||
#### AutocompleteProvider Interface
|
||||
|
||||
```typescript
|
||||
interface AutocompleteProvider {
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string;
|
||||
} | null;
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AutocompleteItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### CombinedAutocompleteProvider
|
||||
|
||||
Built-in provider supporting slash commands and file completion.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```typescript
|
||||
new CombinedAutocompleteProvider(
|
||||
commands: (SlashCommand | AutocompleteItem)[] = [],
|
||||
basePath: string = process.cwd()
|
||||
)
|
||||
|
||||
interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
**Slash Commands:**
|
||||
|
||||
- Type `/` to trigger command completion
|
||||
- Auto-completion for command names
|
||||
- Argument completion for commands that support it
|
||||
- Space after command name for argument input
|
||||
|
||||
**File Completion:**
|
||||
|
||||
- `Tab` key triggers file completion
|
||||
- `@` prefix for file attachments
|
||||
- Home directory expansion (`~/`)
|
||||
- Relative and absolute path support
|
||||
- Directory-first sorting
|
||||
- Filters to attachable files for `@` prefix
|
||||
|
||||
**Path Patterns:**
|
||||
|
||||
- `./` and `../` - Relative paths
|
||||
- `~/` - Home directory
|
||||
- `@path` - File attachment syntax
|
||||
- Tab completion from any context
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `getSuggestions()` - Get completions for current context
|
||||
- `getForceFileSuggestions()` - Force file completion (Tab key)
|
||||
- `shouldTriggerFileCompletion()` - Check if file completion should trigger
|
||||
- `applyCompletion()` - Apply selected completion
|
||||
|
||||
## Differential Rendering
|
||||
|
||||
The core concept: components return `{lines: string[], changed: boolean, keepLines?: number}`:
|
||||
|
||||
- `lines`: All lines the component should display
|
||||
- `changed`: Whether the component has changed since last render
|
||||
- `keepLines`: (Containers only) How many lines from the beginning are unchanged
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. TUI calculates total unchanged lines from top (`keepLines`)
|
||||
2. Moves cursor up by `(totalLines - keepLines)` positions
|
||||
3. Clears from cursor position down with `\x1b[0J`
|
||||
4. Prints only the changing lines: `result.lines.slice(keepLines)`
|
||||
|
||||
This approach minimizes screen updates and provides smooth performance even with large amounts of text.
|
||||
|
||||
**Important:** Don't add extra cursor positioning after printing - it interferes with terminal scrolling and causes rendering artifacts.
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Chat Application with Autocomplete
|
||||
|
||||
```typescript
|
||||
import { TUI, Container, TextEditor, MarkdownComponent, CombinedAutocompleteProvider } from "@mariozechner/pi-tui";
|
||||
|
||||
const ui = new TUI();
|
||||
const chatHistory = new Container();
|
||||
const editor = new TextEditor();
|
||||
|
||||
// Set up autocomplete with slash commands
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider([
|
||||
{ name: "clear", description: "Clear chat history" },
|
||||
{ name: "help", description: "Show help information" },
|
||||
{
|
||||
name: "attach",
|
||||
description: "Attach a file",
|
||||
getArgumentCompletions: (prefix) => {
|
||||
// Return file suggestions for attach command
|
||||
return null; // Use default file completion
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
editor.setAutocompleteProvider(autocompleteProvider);
|
||||
|
||||
editor.onSubmit = (text) => {
|
||||
// Handle slash commands
|
||||
if (text.startsWith("/")) {
|
||||
const [command, ...args] = text.slice(1).split(" ");
|
||||
if (command === "clear") {
|
||||
chatHistory.clear();
|
||||
return;
|
||||
}
|
||||
if (command === "help") {
|
||||
const help = new MarkdownComponent(`
|
||||
## Available Commands
|
||||
- \`/clear\` - Clear chat history
|
||||
- \`/help\` - Show this help
|
||||
- \`/attach <file>\` - Attach a file
|
||||
`);
|
||||
chatHistory.addChild(help);
|
||||
ui.requestRender();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular message
|
||||
const message = new MarkdownComponent(`**You:** ${text}`);
|
||||
chatHistory.addChild(message);
|
||||
|
||||
// Add AI response (simulated)
|
||||
setTimeout(() => {
|
||||
const response = new MarkdownComponent(`**AI:** Response to "${text}"`);
|
||||
chatHistory.addChild(response);
|
||||
ui.requestRender();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ui.addChild(chatHistory);
|
||||
ui.addChild(editor);
|
||||
ui.setFocus(editor);
|
||||
ui.start();
|
||||
```
|
||||
|
||||
### File Browser
|
||||
|
||||
```typescript
|
||||
import { TUI, SelectList } from "@mariozechner/pi-tui";
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const ui = new TUI();
|
||||
let currentPath = process.cwd();
|
||||
|
||||
function createFileList(path: string) {
|
||||
const entries = readdirSync(path).map((entry) => {
|
||||
const fullPath = join(path, entry);
|
||||
const isDir = statSync(fullPath).isDirectory();
|
||||
return {
|
||||
value: entry,
|
||||
label: entry,
|
||||
description: isDir ? "directory" : "file",
|
||||
};
|
||||
});
|
||||
|
||||
// Add parent directory option
|
||||
if (path !== "/") {
|
||||
entries.unshift({
|
||||
value: "..",
|
||||
label: "..",
|
||||
description: "parent directory",
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function showDirectory(path: string) {
|
||||
ui.clear();
|
||||
|
||||
const entries = createFileList(path);
|
||||
const fileList = new SelectList(entries, 10);
|
||||
|
||||
fileList.onSelect = (item) => {
|
||||
if (item.value === "..") {
|
||||
currentPath = join(currentPath, "..");
|
||||
showDirectory(currentPath);
|
||||
} else if (item.description === "directory") {
|
||||
currentPath = join(currentPath, item.value);
|
||||
showDirectory(currentPath);
|
||||
} else {
|
||||
console.log(`Selected file: ${join(currentPath, item.value)}`);
|
||||
ui.stop();
|
||||
}
|
||||
};
|
||||
|
||||
ui.addChild(fileList);
|
||||
ui.setFocus(fileList);
|
||||
}
|
||||
|
||||
showDirectory(currentPath);
|
||||
ui.start();
|
||||
```
|
||||
|
||||
### Multi-Component Layout
|
||||
|
||||
```typescript
|
||||
import { TUI, Container, TextComponent, TextEditor, MarkdownComponent } from "@mariozechner/pi-tui";
|
||||
|
||||
const ui = new TUI();
|
||||
|
||||
// Create layout containers
|
||||
const header = new TextComponent("📝 Advanced TUI Demo", { bottom: 1 });
|
||||
const mainContent = new Container();
|
||||
const sidebar = new Container();
|
||||
const footer = new TextComponent("Press Ctrl+C to exit", { top: 1 });
|
||||
|
||||
// Sidebar content
|
||||
sidebar.addChild(new TextComponent("📁 Files:", { bottom: 1 }));
|
||||
sidebar.addChild(new TextComponent("- config.json"));
|
||||
sidebar.addChild(new TextComponent("- README.md"));
|
||||
sidebar.addChild(new TextComponent("- package.json"));
|
||||
|
||||
// Main content area
|
||||
const chatArea = new Container();
|
||||
const inputArea = new TextEditor();
|
||||
|
||||
// Add welcome message
|
||||
chatArea.addChild(
|
||||
new MarkdownComponent(`
|
||||
# Welcome to the TUI Demo
|
||||
|
||||
This demonstrates multiple components working together:
|
||||
|
||||
- **Header**: Static title with padding
|
||||
- **Sidebar**: File list (simulated)
|
||||
- **Chat Area**: Scrollable message history
|
||||
- **Input**: Interactive text editor
|
||||
- **Footer**: Status information
|
||||
|
||||
Try typing a message and pressing Enter!
|
||||
`),
|
||||
);
|
||||
|
||||
inputArea.onSubmit = (text) => {
|
||||
if (text.trim()) {
|
||||
const message = new MarkdownComponent(`
|
||||
**${new Date().toLocaleTimeString()}:** ${text}
|
||||
`);
|
||||
chatArea.addChild(message);
|
||||
ui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
// Build layout
|
||||
mainContent.addChild(chatArea);
|
||||
mainContent.addChild(inputArea);
|
||||
|
||||
ui.addChild(header);
|
||||
ui.addChild(mainContent);
|
||||
ui.addChild(footer);
|
||||
ui.setFocus(inputArea);
|
||||
|
||||
// Configure debug logging
|
||||
ui.configureLogging({
|
||||
enabled: true,
|
||||
level: "info",
|
||||
logFile: "tui-debug.log",
|
||||
});
|
||||
|
||||
ui.start();
|
||||
```
|
||||
|
||||
## Interfaces and Types
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
interface ContainerRenderResult extends ComponentRenderResult {
|
||||
keepLines: number;
|
||||
}
|
||||
|
||||
interface Component {
|
||||
render(width: number): ComponentRenderResult;
|
||||
handleInput?(keyData: string): void;
|
||||
}
|
||||
|
||||
interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Autocomplete Types
|
||||
|
||||
```typescript
|
||||
interface AutocompleteItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||
}
|
||||
|
||||
interface AutocompleteProvider {
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string;
|
||||
} | null;
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Selection Types
|
||||
|
||||
```typescript
|
||||
interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies (from monorepo root)
|
||||
npm install
|
||||
|
||||
# Build the package
|
||||
npm run build
|
||||
|
||||
# Run type checking
|
||||
npm run check
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
Create a test file and run it with tsx:
|
||||
|
||||
```bash
|
||||
# From packages/tui directory
|
||||
npx tsx test/demo.ts
|
||||
```
|
||||
|
||||
Special input keywords for simulation: "TAB", "ENTER", "SPACE", "ESC"
|
||||
|
||||
**Debugging:**
|
||||
Enable logging to see detailed component behavior:
|
||||
|
||||
```typescript
|
||||
ui.configureLogging({
|
||||
enabled: true,
|
||||
level: "debug", // "error" | "warn" | "info" | "debug"
|
||||
logFile: "tui-debug.log",
|
||||
});
|
||||
```
|
||||
|
||||
Check the log file to debug rendering issues, input handling, and component lifecycle.
|
||||
289
packages/tui/package-lock.json
generated
Normal file
289
packages/tui/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
{
|
||||
"name": "@mariozechner/tui",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@mariozechner/tui",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.4.1",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.3",
|
||||
"@types/node": "^20.19.9",
|
||||
"husky": "^9.1.7",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.1.3.tgz",
|
||||
"integrity": "sha512-KE/tegvJIxTkl7gJbGWSgun7G6X/n2M6C35COT6ctYrAy7SiPyNvi6JtoQERVK/VRbttZfgGq96j2bFmhmnH4w==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.1.3",
|
||||
"@biomejs/cli-darwin-x64": "2.1.3",
|
||||
"@biomejs/cli-linux-arm64": "2.1.3",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.1.3",
|
||||
"@biomejs/cli-linux-x64": "2.1.3",
|
||||
"@biomejs/cli-linux-x64-musl": "2.1.3",
|
||||
"@biomejs/cli-win32-arm64": "2.1.3",
|
||||
"@biomejs/cli-win32-x64": "2.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.1.3.tgz",
|
||||
"integrity": "sha512-LFLkSWRoSGS1wVUD/BE6Nlt2dSn0ulH3XImzg2O/36BoToJHKXjSxzPEMAqT9QvwVtk7/9AQhZpTneERU9qaXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.1.3.tgz",
|
||||
"integrity": "sha512-Q/4OTw8P9No9QeowyxswcWdm0n2MsdCwWcc5NcKQQvzwPjwuPdf8dpPPf4r+x0RWKBtl1FLiAUtJvBlri6DnYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.1.3.tgz",
|
||||
"integrity": "sha512-2hS6LgylRqMFmAZCOFwYrf77QMdUwJp49oe8PX/O8+P2yKZMSpyQTf3Eo5ewnsMFUEmYbPOskafdV1ds1MZMJA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.3.tgz",
|
||||
"integrity": "sha512-KXouFSBnoxAWZYDQrnNRzZBbt5s9UJkIm40hdvSL9mBxSSoxRFQJbtg1hP3aa8A2SnXyQHxQfpiVeJlczZt76w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.1.3.tgz",
|
||||
"integrity": "sha512-NxlSCBhLvQtWGagEztfAZ4WcE1AkMTntZV65ZvR+J9jp06+EtOYEBPQndA70ZGhHbEDG57bR6uNvqkd1WrEYVA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.3.tgz",
|
||||
"integrity": "sha512-KaLAxnROouzIWtl6a0Y88r/4hW5oDUJTIqQorOTVQITaKQsKjZX4XCUmHIhdEk8zMnaiLZzRTAwk1yIAl+mIew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.1.3.tgz",
|
||||
"integrity": "sha512-V9CUZCtWH4u0YwyCYbQ3W5F4ZGPWp2C2TYcsiWFNNyRfmOW1j/TY/jAurl33SaRjgZPO5UUhGyr9m6BN9t84NQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.1.3.tgz",
|
||||
"integrity": "sha512-dxy599q6lgp8ANPpR8sDMscwdp9oOumEsVXuVCVT9N2vAho8uYXlCz53JhxX6LtJOXaE73qzgkGQ7QqvFlMC0g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
|
||||
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
packages/tui/package.json
Normal file
44
packages/tui/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.5.0",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"check": "biome check --write .",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"tui",
|
||||
"terminal",
|
||||
"ui",
|
||||
"text-editor",
|
||||
"differential-rendering",
|
||||
"typescript",
|
||||
"cli"
|
||||
],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/tui"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"types": "./dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.5.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
}
|
||||
}
|
||||
549
packages/tui/src/autocomplete.ts
Normal file
549
packages/tui/src/autocomplete.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import { readdirSync, statSync } from "fs";
|
||||
import mimeTypes from "mime-types";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, extname, join } from "path";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
function isAttachableFile(filePath: string): boolean {
|
||||
const mimeType = mimeTypes.lookup(filePath);
|
||||
|
||||
// Check file extension for common text files that might be misidentified
|
||||
const textExtensions = [
|
||||
".txt",
|
||||
".md",
|
||||
".markdown",
|
||||
".js",
|
||||
".ts",
|
||||
".tsx",
|
||||
".jsx",
|
||||
".py",
|
||||
".java",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".cs",
|
||||
".php",
|
||||
".rb",
|
||||
".go",
|
||||
".rs",
|
||||
".swift",
|
||||
".kt",
|
||||
".scala",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".xml",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".conf",
|
||||
".log",
|
||||
".sql",
|
||||
".r",
|
||||
".R",
|
||||
".m",
|
||||
".pl",
|
||||
".lua",
|
||||
".vim",
|
||||
".dockerfile",
|
||||
".makefile",
|
||||
".cmake",
|
||||
".gradle",
|
||||
".maven",
|
||||
".properties",
|
||||
".env",
|
||||
];
|
||||
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
if (textExtensions.includes(ext)) return true;
|
||||
|
||||
if (!mimeType) return false;
|
||||
|
||||
if (mimeType.startsWith("image/")) return true;
|
||||
if (mimeType.startsWith("text/")) return true;
|
||||
|
||||
// Special cases for common text files that might not be detected as text/
|
||||
const commonTextTypes = [
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/typescript",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
];
|
||||
|
||||
return commonTextTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
export interface AutocompleteItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// Function to get argument completions for this command
|
||||
// Returns null if no argument completion is available
|
||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||
}
|
||||
|
||||
export interface AutocompleteProvider {
|
||||
// Get autocomplete suggestions for current text/cursor position
|
||||
// Returns null if no suggestions available
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||
} | null;
|
||||
|
||||
// Apply the selected item
|
||||
// Returns the new text and cursor position
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Combined provider that handles both slash commands and file paths
|
||||
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
private commands: (SlashCommand | AutocompleteItem)[];
|
||||
private basePath: string;
|
||||
|
||||
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {
|
||||
this.commands = commands;
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
logger.debug("CombinedAutocompleteProvider", "getSuggestions called", {
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
});
|
||||
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Check for slash commands
|
||||
if (textBeforeCursor.startsWith("/")) {
|
||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
// No space yet - complete command names
|
||||
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
||||
const filtered = this.commands
|
||||
.filter((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem
|
||||
return name?.toLowerCase().startsWith(prefix.toLowerCase());
|
||||
})
|
||||
.map((cmd) => ({
|
||||
value: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
...(cmd.description && { description: cmd.description }),
|
||||
}));
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
prefix: textBeforeCursor,
|
||||
};
|
||||
} else {
|
||||
// Space found - complete command arguments
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
||||
|
||||
const command = this.commands.find((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
return name === commandName;
|
||||
});
|
||||
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
||||
return null; // No argument completion for this command
|
||||
}
|
||||
|
||||
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
||||
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: argumentSuggestions,
|
||||
prefix: argumentText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file paths - triggered by Tab or if we detect a path pattern
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
|
||||
logger.debug("CombinedAutocompleteProvider", "Path match check", {
|
||||
textBeforeCursor,
|
||||
pathMatch,
|
||||
});
|
||||
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
||||
const afterCursor = currentLine.slice(cursorCol);
|
||||
|
||||
// Check if we're completing a slash command (prefix starts with "/")
|
||||
if (prefix.startsWith("/")) {
|
||||
// This is a command name completion
|
||||
const newLine = beforePrefix + "/" + item.value + " " + afterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing a file attachment (prefix starts with "@")
|
||||
if (prefix.startsWith("@")) {
|
||||
// This is a file attachment completion
|
||||
const newLine = beforePrefix + item.value + " " + afterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + item.value.length + 1, // +1 for space
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're in a slash command context (beforePrefix contains "/command ")
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
|
||||
// This is likely a command argument completion
|
||||
const newLine = beforePrefix + item.value + afterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + item.value.length,
|
||||
};
|
||||
}
|
||||
|
||||
// For file paths, complete the path
|
||||
const newLine = beforePrefix + item.value + afterCursor;
|
||||
const newLines = [...lines];
|
||||
newLines[cursorLine] = newLine;
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
cursorLine,
|
||||
cursorCol: beforePrefix.length + item.value.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract a path-like prefix from the text before cursor
|
||||
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
|
||||
// Check for @ file attachment syntax first
|
||||
const atMatch = text.match(/@([^\s]*)$/);
|
||||
if (atMatch) {
|
||||
return atMatch[0]; // Return the full @path pattern
|
||||
}
|
||||
|
||||
// Match paths - including those ending with /, ~/, or any word at end for forced extraction
|
||||
// This regex captures:
|
||||
// - Paths starting from beginning of line or after space/quote/equals
|
||||
// - Optional ./ or ../ or ~/ prefix (including the trailing slash for ~/)
|
||||
// - The path itself (can include / in the middle)
|
||||
// - For forced extraction, capture any word at the end
|
||||
const matches = text.match(/(?:^|[\s"'=])((?:~\/|\.{0,2}\/?)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/);
|
||||
if (!matches) {
|
||||
// If forced extraction and no matches, return empty string to trigger from current dir
|
||||
return forceExtract ? "" : null;
|
||||
}
|
||||
|
||||
const pathPrefix = matches[1] || "";
|
||||
|
||||
// For forced extraction (Tab key), always return something
|
||||
if (forceExtract) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
|
||||
// Only return empty string if the text looks like it's starting a path context
|
||||
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// Return empty string only if we're at the beginning of the line or after a space
|
||||
// (not after quotes or other delimiters that don't suggest file paths)
|
||||
if (pathPrefix === "" && (text === "" || text.endsWith(" "))) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand home directory (~/) to actual home path
|
||||
private expandHomePath(path: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
const expandedPath = join(homedir(), path.slice(2));
|
||||
// Preserve trailing slash if original path had one
|
||||
return path.endsWith("/") && !expandedPath.endsWith("/") ? expandedPath + "/" : expandedPath;
|
||||
} else if (path === "~") {
|
||||
return homedir();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Get file/directory suggestions for a given path prefix
|
||||
private getFileSuggestions(prefix: string): AutocompleteItem[] {
|
||||
logger.debug("CombinedAutocompleteProvider", "getFileSuggestions called", {
|
||||
prefix,
|
||||
basePath: this.basePath,
|
||||
});
|
||||
|
||||
try {
|
||||
let searchDir: string;
|
||||
let searchPrefix: string;
|
||||
let expandedPrefix = prefix;
|
||||
let isAtPrefix = false;
|
||||
|
||||
// Handle @ file attachment prefix
|
||||
if (prefix.startsWith("@")) {
|
||||
isAtPrefix = true;
|
||||
expandedPrefix = prefix.slice(1); // Remove the @
|
||||
}
|
||||
|
||||
// Handle home directory expansion
|
||||
if (expandedPrefix.startsWith("~")) {
|
||||
expandedPrefix = this.expandHomePath(expandedPrefix);
|
||||
}
|
||||
|
||||
if (
|
||||
expandedPrefix === "" ||
|
||||
expandedPrefix === "./" ||
|
||||
expandedPrefix === "../" ||
|
||||
expandedPrefix === "~" ||
|
||||
expandedPrefix === "~/" ||
|
||||
prefix === "@"
|
||||
) {
|
||||
// Complete from specified position
|
||||
if (prefix.startsWith("~")) {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
}
|
||||
searchPrefix = "";
|
||||
} else if (expandedPrefix.endsWith("/")) {
|
||||
// If prefix ends with /, show contents of that directory
|
||||
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
|
||||
searchDir = expandedPrefix;
|
||||
} else {
|
||||
searchDir = join(this.basePath, expandedPrefix);
|
||||
}
|
||||
searchPrefix = "";
|
||||
} else {
|
||||
// Split into directory and file prefix
|
||||
const dir = dirname(expandedPrefix);
|
||||
const file = basename(expandedPrefix);
|
||||
if (prefix.startsWith("~") || (isAtPrefix && expandedPrefix.startsWith("/"))) {
|
||||
searchDir = dir;
|
||||
} else {
|
||||
searchDir = join(this.basePath, dir);
|
||||
}
|
||||
searchPrefix = file;
|
||||
}
|
||||
|
||||
logger.debug("CombinedAutocompleteProvider", "Searching directory", {
|
||||
searchDir,
|
||||
searchPrefix,
|
||||
});
|
||||
|
||||
const entries = readdirSync(searchDir);
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = join(searchDir, entry);
|
||||
const isDirectory = statSync(fullPath).isDirectory();
|
||||
|
||||
// For @ prefix, filter to only show directories and attachable files
|
||||
if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relativePath: string;
|
||||
|
||||
// Handle @ prefix path construction
|
||||
if (isAtPrefix) {
|
||||
const pathWithoutAt = expandedPrefix;
|
||||
if (pathWithoutAt.endsWith("/")) {
|
||||
relativePath = "@" + pathWithoutAt + entry;
|
||||
} else if (pathWithoutAt.includes("/")) {
|
||||
if (pathWithoutAt.startsWith("~/")) {
|
||||
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
|
||||
const dir = dirname(homeRelativeDir);
|
||||
relativePath = "@~/" + (dir === "." ? entry : join(dir, entry));
|
||||
} else {
|
||||
relativePath = "@" + join(dirname(pathWithoutAt), entry);
|
||||
}
|
||||
} else {
|
||||
if (pathWithoutAt.startsWith("~")) {
|
||||
relativePath = "@~/" + entry;
|
||||
} else {
|
||||
relativePath = "@" + entry;
|
||||
}
|
||||
}
|
||||
} else if (prefix.endsWith("/")) {
|
||||
// If prefix ends with /, append entry to the prefix
|
||||
relativePath = prefix + entry;
|
||||
} else if (prefix.includes("/")) {
|
||||
// Preserve ~/ format for home directory paths
|
||||
if (prefix.startsWith("~/")) {
|
||||
const homeRelativeDir = prefix.slice(2); // Remove ~/
|
||||
const dir = dirname(homeRelativeDir);
|
||||
relativePath = "~/" + (dir === "." ? entry : join(dir, entry));
|
||||
} else {
|
||||
relativePath = join(dirname(prefix), entry);
|
||||
}
|
||||
} else {
|
||||
// For standalone entries, preserve ~/ if original prefix was ~/
|
||||
if (prefix.startsWith("~")) {
|
||||
relativePath = "~/" + entry;
|
||||
} else {
|
||||
relativePath = entry;
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
value: isDirectory ? relativePath + "/" : relativePath,
|
||||
label: entry,
|
||||
description: isDirectory ? "directory" : "file",
|
||||
});
|
||||
}
|
||||
|
||||
// Sort directories first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
const aIsDir = a.description === "directory";
|
||||
const bIsDir = b.description === "directory";
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
logger.debug("CombinedAutocompleteProvider", "Returning suggestions", {
|
||||
count: suggestions.length,
|
||||
firstFew: suggestions.slice(0, 3).map((s) => s.label),
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 10); // Limit to 10 suggestions
|
||||
} catch (e) {
|
||||
// Directory doesn't exist or not accessible
|
||||
logger.error("CombinedAutocompleteProvider", "Error reading directory", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Force file completion (called on Tab key) - always returns suggestions
|
||||
getForceFileSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
logger.debug("CombinedAutocompleteProvider", "getForceFileSuggestions called", {
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
});
|
||||
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're in a slash command
|
||||
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Force extract path prefix - this will always return something
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
|
||||
logger.debug("CombinedAutocompleteProvider", "Forced path match", {
|
||||
textBeforeCursor,
|
||||
pathMatch,
|
||||
});
|
||||
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we should trigger file completion (called on Tab key)
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're in a slash command
|
||||
if (textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
29
packages/tui/src/index.ts
Normal file
29
packages/tui/src/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Core TUI interfaces and classes
|
||||
|
||||
// Autocomplete support
|
||||
export {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
CombinedAutocompleteProvider,
|
||||
type SlashCommand,
|
||||
} from "./autocomplete.js";
|
||||
// Logger for debugging
|
||||
export { type LoggerConfig, logger } from "./logger.js";
|
||||
// Markdown component
|
||||
export { MarkdownComponent } from "./markdown-component.js";
|
||||
// Select list component
|
||||
export { type SelectItem, SelectList } from "./select-list.js";
|
||||
// Text component
|
||||
export { TextComponent } from "./text-component.js";
|
||||
// Text editor component
|
||||
export { TextEditor, type TextEditorConfig } from "./text-editor.js";
|
||||
export {
|
||||
type Component,
|
||||
type ComponentRenderResult,
|
||||
Container,
|
||||
type ContainerRenderResult,
|
||||
type Padding,
|
||||
TUI,
|
||||
} from "./tui.js";
|
||||
// Whitespace component
|
||||
export { WhitespaceComponent } from "./whitespace-component.js";
|
||||
95
packages/tui/src/logger.ts
Normal file
95
packages/tui/src/logger.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { appendFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
export interface LoggerConfig {
|
||||
enabled: boolean;
|
||||
logFile: string;
|
||||
logLevel: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig = {
|
||||
enabled: false,
|
||||
logFile: join(process.cwd(), "tui-debug.log"),
|
||||
logLevel: "debug",
|
||||
};
|
||||
|
||||
configure(config: Partial<LoggerConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
if (this.config.enabled) {
|
||||
// Clear log file on startup
|
||||
try {
|
||||
writeFileSync(this.config.logFile, `=== TUI Debug Log Started ${new Date().toISOString()} ===\n`);
|
||||
} catch (error) {
|
||||
// Silently fail if we can't write to log file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: string): boolean {
|
||||
if (!this.config.enabled) return false;
|
||||
|
||||
const levels = ["debug", "info", "warn", "error"];
|
||||
const currentLevel = levels.indexOf(this.config.logLevel);
|
||||
const messageLevel = levels.indexOf(level);
|
||||
|
||||
return messageLevel >= currentLevel;
|
||||
}
|
||||
|
||||
private log(level: string, component: string, message: string, data?: any): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const dataStr = data ? ` | Data: ${JSON.stringify(data)}` : "";
|
||||
const logLine = `[${timestamp}] ${level.toUpperCase()} [${component}] ${message}${dataStr}\n`;
|
||||
|
||||
appendFileSync(this.config.logFile, logLine);
|
||||
} catch (error) {
|
||||
// Silently fail if we can't write to log file
|
||||
}
|
||||
}
|
||||
|
||||
debug(component: string, message: string, data?: any): void {
|
||||
this.log("debug", component, message, data);
|
||||
}
|
||||
|
||||
info(component: string, message: string, data?: any): void {
|
||||
this.log("info", component, message, data);
|
||||
}
|
||||
|
||||
warn(component: string, message: string, data?: any): void {
|
||||
this.log("warn", component, message, data);
|
||||
}
|
||||
|
||||
error(component: string, message: string, data?: any): void {
|
||||
this.log("error", component, message, data);
|
||||
}
|
||||
|
||||
// Specific TUI logging methods
|
||||
keyInput(component: string, keyData: string): void {
|
||||
this.debug(component, "Key input received", {
|
||||
keyData,
|
||||
charCodes: Array.from(keyData).map((c) => c.charCodeAt(0)),
|
||||
});
|
||||
}
|
||||
|
||||
render(component: string, renderResult: any): void {
|
||||
this.debug(component, "Render result", renderResult);
|
||||
}
|
||||
|
||||
focus(component: string, focused: boolean): void {
|
||||
this.info(component, `Focus ${focused ? "gained" : "lost"}`);
|
||||
}
|
||||
|
||||
componentLifecycle(component: string, action: string, details?: any): void {
|
||||
this.info(component, `Component ${action}`, details);
|
||||
}
|
||||
|
||||
stateChange(component: string, property: string, oldValue: any, newValue: any): void {
|
||||
this.debug(component, `State change: ${property}`, { oldValue, newValue });
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
260
packages/tui/src/markdown-component.ts
Normal file
260
packages/tui/src/markdown-component.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import chalk from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import type { Component, ComponentRenderResult } from "./tui.js";
|
||||
|
||||
export class MarkdownComponent implements Component {
|
||||
private text: string;
|
||||
private lines: string[] = [];
|
||||
private previousLines: string[] = [];
|
||||
|
||||
constructor(text: string = "") {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Parse markdown to HTML-like tokens
|
||||
const tokens = marked.lexer(this.text);
|
||||
|
||||
// Convert tokens to styled terminal output
|
||||
const renderedLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const nextToken = tokens[i + 1];
|
||||
const tokenLines = this.renderToken(token, width, nextToken?.type);
|
||||
renderedLines.push(...tokenLines);
|
||||
}
|
||||
|
||||
// Wrap lines to fit width
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of renderedLines) {
|
||||
wrappedLines.push(...this.wrapLine(line, width));
|
||||
}
|
||||
|
||||
this.previousLines = this.lines;
|
||||
this.lines = wrappedLines;
|
||||
|
||||
// Determine if content changed
|
||||
const changed =
|
||||
this.lines.length !== this.previousLines.length ||
|
||||
this.lines.some((line, i) => line !== this.previousLines[i]);
|
||||
|
||||
return {
|
||||
lines: this.lines,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (token.type) {
|
||||
case "heading": {
|
||||
const headingLevel = token.depth;
|
||||
const headingPrefix = "#".repeat(headingLevel) + " ";
|
||||
const headingText = this.renderInlineTokens(token.tokens || []);
|
||||
if (headingLevel === 1) {
|
||||
lines.push(chalk.bold.underline.yellow(headingText));
|
||||
} else if (headingLevel === 2) {
|
||||
lines.push(chalk.bold.yellow(headingText));
|
||||
} else {
|
||||
lines.push(chalk.bold(headingPrefix + headingText));
|
||||
}
|
||||
lines.push(""); // Add spacing after headings
|
||||
break;
|
||||
}
|
||||
|
||||
case "paragraph": {
|
||||
const paragraphText = this.renderInlineTokens(token.tokens || []);
|
||||
lines.push(paragraphText);
|
||||
// Don't add spacing if next token is space or list
|
||||
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
|
||||
lines.push("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "code": {
|
||||
lines.push(chalk.gray("```" + (token.lang || "")));
|
||||
// Split code by newlines and style each line
|
||||
const codeLines = token.text.split("\n");
|
||||
for (const codeLine of codeLines) {
|
||||
lines.push(chalk.dim(" ") + chalk.green(codeLine));
|
||||
}
|
||||
lines.push(chalk.gray("```"));
|
||||
lines.push(""); // Add spacing after code blocks
|
||||
break;
|
||||
}
|
||||
|
||||
case "list":
|
||||
for (let i = 0; i < token.items.length; i++) {
|
||||
const item = token.items[i];
|
||||
const bullet = token.ordered ? `${i + 1}. ` : "- ";
|
||||
const itemText = this.renderInlineTokens(item.tokens || []);
|
||||
|
||||
// Check if the item text contains multiple lines (embedded content)
|
||||
const itemLines = itemText.split("\n").filter((line) => line.trim());
|
||||
if (itemLines.length > 1) {
|
||||
// First line is the list item
|
||||
lines.push(chalk.cyan(bullet) + itemLines[0]);
|
||||
// Rest are treated as separate content
|
||||
for (let j = 1; j < itemLines.length; j++) {
|
||||
lines.push(""); // Add spacing
|
||||
lines.push(itemLines[j]);
|
||||
}
|
||||
} else {
|
||||
lines.push(chalk.cyan(bullet) + itemText);
|
||||
}
|
||||
}
|
||||
// Don't add spacing after lists if a space token follows
|
||||
// (the space token will handle it)
|
||||
break;
|
||||
|
||||
case "blockquote": {
|
||||
const quoteText = this.renderInlineTokens(token.tokens || []);
|
||||
const quoteLines = quoteText.split("\n");
|
||||
for (const quoteLine of quoteLines) {
|
||||
lines.push(chalk.gray("│ ") + chalk.italic(quoteLine));
|
||||
}
|
||||
lines.push(""); // Add spacing after blockquotes
|
||||
break;
|
||||
}
|
||||
|
||||
case "hr":
|
||||
lines.push(chalk.gray("─".repeat(Math.min(width, 80))));
|
||||
lines.push(""); // Add spacing after horizontal rules
|
||||
break;
|
||||
|
||||
case "html":
|
||||
// Skip HTML for terminal output
|
||||
break;
|
||||
|
||||
case "space":
|
||||
// Space tokens represent blank lines in markdown
|
||||
lines.push("");
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle any other token types as plain text
|
||||
if ("text" in token && typeof token.text === "string") {
|
||||
lines.push(token.text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderInlineTokens(tokens: Token[]): string {
|
||||
let result = "";
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "text":
|
||||
// Text tokens in list items can have nested tokens for inline formatting
|
||||
if (token.tokens && token.tokens.length > 0) {
|
||||
result += this.renderInlineTokens(token.tokens);
|
||||
} else {
|
||||
result += token.text;
|
||||
}
|
||||
break;
|
||||
|
||||
case "strong":
|
||||
result += chalk.bold(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
case "em":
|
||||
result += chalk.italic(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
case "codespan":
|
||||
result += chalk.gray("`") + chalk.cyan(token.text) + chalk.gray("`");
|
||||
break;
|
||||
|
||||
case "link": {
|
||||
const linkText = this.renderInlineTokens(token.tokens || []);
|
||||
result += chalk.underline.blue(linkText) + chalk.gray(` (${token.href})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "br":
|
||||
result += "\n";
|
||||
break;
|
||||
|
||||
case "del":
|
||||
result += chalk.strikethrough(this.renderInlineTokens(token.tokens || []));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle any other inline token types as plain text
|
||||
if ("text" in token && typeof token.text === "string") {
|
||||
result += token.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private wrapLine(line: string, width: number): string[] {
|
||||
// Handle ANSI escape codes properly when wrapping
|
||||
const wrapped: string[] = [];
|
||||
|
||||
// Handle undefined or null lines
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// If line fits within width, return as-is
|
||||
const visibleLength = this.getVisibleLength(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
// Need to wrap - this is complex with ANSI codes
|
||||
// For now, use a simple approach that may break styling at wrap points
|
||||
let currentLine = "";
|
||||
let currentLength = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
if (line[i] === "\x1b" && line[i + 1] === "[") {
|
||||
// ANSI escape sequence - include it without counting length
|
||||
let j = i + 2;
|
||||
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
||||
j++;
|
||||
}
|
||||
if (j < line.length) {
|
||||
currentLine += line.substring(i, j + 1);
|
||||
i = j + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
if (currentLength >= width) {
|
||||
wrapped.push(currentLine);
|
||||
currentLine = "";
|
||||
currentLength = 0;
|
||||
}
|
||||
currentLine += line[i];
|
||||
currentLength++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
private getVisibleLength(str: string): number {
|
||||
// Remove ANSI escape codes and count visible characters
|
||||
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
}
|
||||
154
packages/tui/src/select-list.ts
Normal file
154
packages/tui/src/select-list.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import chalk from "chalk";
|
||||
import type { Component, ComponentRenderResult } from "./tui.js";
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SelectList implements Component {
|
||||
private items: SelectItem[] = [];
|
||||
private filteredItems: SelectItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private filter: string = "";
|
||||
private maxVisible: number = 5;
|
||||
|
||||
public onSelect?: (item: SelectItem) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(items: SelectItem[], maxVisible: number = 5) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
}
|
||||
|
||||
setFilter(filter: string): void {
|
||||
this.filter = filter;
|
||||
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
|
||||
// Reset selection when filter changes
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
const lines: string[] = [];
|
||||
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(chalk.gray(" No matching commands"));
|
||||
return { lines, changed: true };
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = this.filteredItems[i];
|
||||
if (!item) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
// Use arrow indicator for selection
|
||||
const prefix = chalk.blue("→ ");
|
||||
const displayValue = item.label || item.value;
|
||||
|
||||
if (item.description && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueLength = Math.min(displayValue.length, 30);
|
||||
const truncatedValue = displayValue.substring(0, maxValueLength);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description
|
||||
const descriptionStart = prefix.length + truncatedValue.length + spacing.length - 2; // -2 for arrow color codes
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = item.description.substring(0, remainingWidth);
|
||||
line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc);
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
|
||||
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - 4; // 2 for arrow + space, 2 for safety
|
||||
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
|
||||
}
|
||||
} else {
|
||||
const displayValue = item.label || item.value;
|
||||
const prefix = " ";
|
||||
|
||||
if (item.description && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueLength = Math.min(displayValue.length, 30);
|
||||
const truncatedValue = displayValue.substring(0, maxValueLength);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description
|
||||
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = item.description.substring(0, remainingWidth);
|
||||
line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc);
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + displayValue.substring(0, maxWidth);
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + displayValue.substring(0, maxWidth);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// Add scroll indicators if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
return { lines, changed: true };
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
}
|
||||
// Down arrow
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem && this.onSelect) {
|
||||
this.onSelect(selectedItem);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
return item || null;
|
||||
}
|
||||
}
|
||||
104
packages/tui/src/text-component.ts
Normal file
104
packages/tui/src/text-component.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { Component, ComponentRenderResult, Padding } from "./tui.js";
|
||||
|
||||
export class TextComponent implements Component {
|
||||
private text: string;
|
||||
private lastRenderedLines: string[] = [];
|
||||
private padding: Required<Padding>;
|
||||
|
||||
constructor(text: string, padding?: Padding) {
|
||||
this.text = text;
|
||||
this.padding = {
|
||||
top: padding?.top ?? 0,
|
||||
bottom: padding?.bottom ?? 0,
|
||||
left: padding?.left ?? 0,
|
||||
right: padding?.right ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Calculate available width after horizontal padding
|
||||
const availableWidth = Math.max(1, width - this.padding.left - this.padding.right);
|
||||
const leftPadding = " ".repeat(this.padding.left);
|
||||
|
||||
// First split by newlines to preserve line breaks
|
||||
const textLines = this.text.split("\n");
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add top padding
|
||||
for (let i = 0; i < this.padding.top; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Process each line for word wrapping
|
||||
for (const textLine of textLines) {
|
||||
if (textLine.length === 0) {
|
||||
// Preserve empty lines with padding
|
||||
lines.push(leftPadding);
|
||||
} else {
|
||||
// Word wrapping with ANSI-aware length calculation
|
||||
const words = textLine.split(" ");
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const wordVisibleLength = this.getVisibleLength(word);
|
||||
const spaceLength = currentLine ? 1 : 0;
|
||||
|
||||
if (currentVisibleLength + spaceLength + wordVisibleLength <= availableWidth) {
|
||||
currentLine += (currentLine ? " " : "") + word;
|
||||
currentVisibleLength += spaceLength + wordVisibleLength;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
lines.push(leftPadding + currentLine);
|
||||
}
|
||||
currentLine = word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(leftPadding + currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bottom padding
|
||||
for (let i = 0; i < this.padding.bottom; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const newLines = lines.length > 0 ? lines : [""];
|
||||
|
||||
// Check if content changed
|
||||
const changed = !this.arraysEqual(newLines, this.lastRenderedLines);
|
||||
|
||||
// Always cache the current rendered lines
|
||||
this.lastRenderedLines = [...newLines];
|
||||
|
||||
return {
|
||||
lines: newLines,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
private arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private getVisibleLength(str: string): number {
|
||||
// Remove ANSI escape codes and count visible characters
|
||||
return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
}
|
||||
802
packages/tui/src/text-editor.ts
Normal file
802
packages/tui/src/text-editor.ts
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
import chalk from "chalk";
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "./autocomplete.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { SelectList } from "./select-list.js";
|
||||
import type { Component, ComponentRenderResult } from "./tui.js";
|
||||
|
||||
interface EditorState {
|
||||
lines: string[];
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
}
|
||||
|
||||
interface LayoutLine {
|
||||
text: string;
|
||||
hasCursor: boolean;
|
||||
cursorPos?: number;
|
||||
}
|
||||
|
||||
export interface TextEditorConfig {
|
||||
// Configuration options for text editor (none currently)
|
||||
}
|
||||
|
||||
export class TextEditor implements Component {
|
||||
private state: EditorState = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
|
||||
private config: TextEditorConfig = {};
|
||||
|
||||
// Autocomplete support
|
||||
private autocompleteProvider?: AutocompleteProvider;
|
||||
private autocompleteList?: SelectList;
|
||||
private isAutocompleting: boolean = false;
|
||||
private autocompletePrefix: string = "";
|
||||
|
||||
public onSubmit?: (text: string) => void;
|
||||
public onChange?: (text: string) => void;
|
||||
public disableSubmit: boolean = false;
|
||||
|
||||
constructor(config?: TextEditorConfig) {
|
||||
if (config) {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
logger.componentLifecycle("TextEditor", "created", { config: this.config });
|
||||
}
|
||||
|
||||
configure(config: Partial<TextEditorConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
logger.info("TextEditor", "Configuration updated", { config: this.config });
|
||||
}
|
||||
|
||||
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
||||
this.autocompleteProvider = provider;
|
||||
}
|
||||
|
||||
render(width: number): ComponentRenderResult {
|
||||
// Box drawing characters
|
||||
const topLeft = chalk.gray("╭");
|
||||
const topRight = chalk.gray("╮");
|
||||
const bottomLeft = chalk.gray("╰");
|
||||
const bottomRight = chalk.gray("╯");
|
||||
const horizontal = chalk.gray("─");
|
||||
const vertical = chalk.gray("│");
|
||||
|
||||
// Calculate box width (leave some margin)
|
||||
const boxWidth = width - 1;
|
||||
const contentWidth = boxWidth - 4; // Account for "│ " and " │"
|
||||
|
||||
// Layout the text
|
||||
const layoutLines = this.layoutText(contentWidth);
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
// Render top border
|
||||
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
|
||||
|
||||
// Render each layout line
|
||||
for (const layoutLine of layoutLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let visibleLength = layoutLine.text.length;
|
||||
|
||||
// Add cursor if this line has it
|
||||
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
||||
const before = displayText.slice(0, layoutLine.cursorPos);
|
||||
const after = displayText.slice(layoutLine.cursorPos);
|
||||
|
||||
if (after.length > 0) {
|
||||
// Cursor is on a character - replace it with highlighted version
|
||||
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
|
||||
const restAfter = after.slice(1);
|
||||
displayText = before + cursor + restAfter;
|
||||
// visibleLength stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
displayText = before + cursor;
|
||||
// visibleLength increases by 1 - we're adding a space
|
||||
visibleLength = layoutLine.text.length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate padding based on actual visible length
|
||||
const padding = " ".repeat(Math.max(0, contentWidth - visibleLength));
|
||||
|
||||
// Render the line
|
||||
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
|
||||
}
|
||||
|
||||
// Render bottom border
|
||||
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
|
||||
|
||||
// Add autocomplete list if active
|
||||
if (this.isAutocompleting && this.autocompleteList) {
|
||||
const autocompleteResult = this.autocompleteList.render(width);
|
||||
result.push(...autocompleteResult.lines);
|
||||
}
|
||||
|
||||
// For interactive components like text editors, always assume changed
|
||||
// This ensures cursor position updates are always reflected
|
||||
return {
|
||||
lines: result,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
logger.keyInput("TextEditor", data);
|
||||
logger.debug("TextEditor", "Current state before input", {
|
||||
lines: this.state.lines,
|
||||
cursorLine: this.state.cursorLine,
|
||||
cursorCol: this.state.cursorCol,
|
||||
});
|
||||
|
||||
// Handle special key combinations first
|
||||
|
||||
// Ctrl+C - Exit (let parent handle this)
|
||||
if (data.charCodeAt(0) === 3) {
|
||||
logger.debug("TextEditor", "Ctrl+C received, returning to parent");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle paste - detect when we get a lot of text at once
|
||||
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
|
||||
logger.debug("TextEditor", "Paste detection", {
|
||||
dataLength: data.length,
|
||||
includesNewline: data.includes("\n"),
|
||||
includesTabs: data.includes("\t"),
|
||||
tabCount: (data.match(/\t/g) || []).length,
|
||||
isPaste,
|
||||
data: JSON.stringify(data),
|
||||
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
||||
});
|
||||
|
||||
if (isPaste) {
|
||||
logger.info("TextEditor", "Handling as paste");
|
||||
this.handlePaste(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle autocomplete special keys first (but don't block other input)
|
||||
if (this.isAutocompleting && this.autocompleteList) {
|
||||
logger.debug("TextEditor", "Autocomplete active, handling input", {
|
||||
data,
|
||||
charCode: data.charCodeAt(0),
|
||||
isEscape: data === "\x1b",
|
||||
isArrowOrEnter: data === "\x1b[A" || data === "\x1b[B" || data === "\r",
|
||||
});
|
||||
|
||||
// Escape - cancel autocomplete
|
||||
if (data === "\x1b") {
|
||||
this.cancelAutocomplete();
|
||||
return;
|
||||
}
|
||||
// Let the autocomplete list handle navigation and selection
|
||||
else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") {
|
||||
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
|
||||
if (data === "\x1b[A" || data === "\x1b[B") {
|
||||
this.autocompleteList.handleInput(data);
|
||||
}
|
||||
|
||||
// If Tab was pressed, apply the selection
|
||||
if (data === "\t") {
|
||||
const selected = this.autocompleteList.getSelectedItem();
|
||||
if (selected && this.autocompleteProvider) {
|
||||
const result = this.autocompleteProvider.applyCompletion(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
selected,
|
||||
this.autocompletePrefix,
|
||||
);
|
||||
|
||||
this.state.lines = result.lines;
|
||||
this.state.cursorLine = result.cursorLine;
|
||||
this.state.cursorCol = result.cursorCol;
|
||||
|
||||
this.cancelAutocomplete();
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If Enter was pressed, cancel autocomplete and let it fall through to submission
|
||||
else if (data === "\r") {
|
||||
this.cancelAutocomplete();
|
||||
// Don't return here - let Enter fall through to normal submission handling
|
||||
} else {
|
||||
// For other keys, handle normally within autocomplete
|
||||
return;
|
||||
}
|
||||
}
|
||||
// For other keys (like regular typing), DON'T return here
|
||||
// Let them fall through to normal character handling
|
||||
logger.debug("TextEditor", "Autocomplete active but falling through to normal handling");
|
||||
}
|
||||
|
||||
// Tab key - context-aware completion (but not when already autocompleting)
|
||||
if (data === "\t" && !this.isAutocompleting) {
|
||||
logger.debug("TextEditor", "Tab key pressed, determining context", {
|
||||
isAutocompleting: this.isAutocompleting,
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
this.handleTabCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with rest of input handling
|
||||
// Ctrl+K - Delete current line
|
||||
if (data.charCodeAt(0) === 11) {
|
||||
this.deleteCurrentLine();
|
||||
}
|
||||
// Ctrl+A - Move to start of line
|
||||
else if (data.charCodeAt(0) === 1) {
|
||||
this.moveToLineStart();
|
||||
}
|
||||
// Ctrl+E - Move to end of line
|
||||
else if (data.charCodeAt(0) === 5) {
|
||||
this.moveToLineEnd();
|
||||
}
|
||||
// New line shortcuts (but not plain LF/CR which should be submit)
|
||||
else if (
|
||||
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
||||
data === "\x1b\r" || // Option+Enter in some terminals
|
||||
data === "\x1b[13;2~" || // Shift+Enter in some terminals
|
||||
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
||||
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
||||
data === "\\\r" // Shift+Enter in VS Code terminal
|
||||
) {
|
||||
// Modifier + Enter = new line
|
||||
this.addNewLine();
|
||||
}
|
||||
// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
|
||||
else if (data.charCodeAt(0) === 13 && data.length === 1) {
|
||||
// If submit is disabled, do nothing
|
||||
if (this.disableSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain Enter = submit
|
||||
const result = this.state.lines.join("\n").trim();
|
||||
logger.info("TextEditor", "Submit triggered", {
|
||||
result,
|
||||
rawResult: JSON.stringify(this.state.lines.join("\n")),
|
||||
lines: this.state.lines,
|
||||
resultLines: result.split("\n"),
|
||||
});
|
||||
|
||||
// Reset editor
|
||||
this.state = {
|
||||
lines: [""],
|
||||
cursorLine: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
|
||||
// Notify that editor is now empty
|
||||
if (this.onChange) {
|
||||
this.onChange("");
|
||||
}
|
||||
|
||||
if (this.onSubmit) {
|
||||
logger.info("TextEditor", "Calling onSubmit callback", { result });
|
||||
this.onSubmit(result);
|
||||
} else {
|
||||
logger.warn("TextEditor", "No onSubmit callback set");
|
||||
}
|
||||
}
|
||||
// Backspace
|
||||
else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {
|
||||
this.handleBackspace();
|
||||
}
|
||||
// Line navigation shortcuts (Home/End keys)
|
||||
else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") {
|
||||
// Home key
|
||||
this.moveToLineStart();
|
||||
} else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") {
|
||||
// End key
|
||||
this.moveToLineEnd();
|
||||
}
|
||||
// Forward delete (Fn+Backspace or Delete key)
|
||||
else if (data === "\x1b[3~") {
|
||||
// Delete key
|
||||
this.handleForwardDelete();
|
||||
}
|
||||
// Arrow keys
|
||||
else if (data === "\x1b[A") {
|
||||
// Up
|
||||
this.moveCursor(-1, 0);
|
||||
} else if (data === "\x1b[B") {
|
||||
// Down
|
||||
this.moveCursor(1, 0);
|
||||
} else if (data === "\x1b[C") {
|
||||
// Right
|
||||
this.moveCursor(0, 1);
|
||||
} else if (data === "\x1b[D") {
|
||||
// Left
|
||||
this.moveCursor(0, -1);
|
||||
}
|
||||
// Regular characters (printable ASCII)
|
||||
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
|
||||
logger.debug("TextEditor", "Inserting character", { char: data, charCode: data.charCodeAt(0) });
|
||||
this.insertCharacter(data);
|
||||
} else {
|
||||
logger.warn("TextEditor", "Unhandled input", {
|
||||
data,
|
||||
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private layoutText(contentWidth: number): LayoutLine[] {
|
||||
const layoutLines: LayoutLine[] = [];
|
||||
|
||||
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
||||
// Empty editor
|
||||
layoutLines.push({
|
||||
text: "> ",
|
||||
hasCursor: true,
|
||||
cursorPos: 2,
|
||||
});
|
||||
return layoutLines;
|
||||
}
|
||||
|
||||
// Process each logical line
|
||||
for (let i = 0; i < this.state.lines.length; i++) {
|
||||
const line = this.state.lines[i] || "";
|
||||
const isCurrentLine = i === this.state.cursorLine;
|
||||
const prefix = i === 0 ? "> " : " ";
|
||||
const prefixedLine = prefix + line;
|
||||
const maxLineLength = contentWidth;
|
||||
|
||||
if (prefixedLine.length <= maxLineLength) {
|
||||
// Line fits in one layout line
|
||||
if (isCurrentLine) {
|
||||
layoutLines.push({
|
||||
text: prefixedLine,
|
||||
hasCursor: true,
|
||||
cursorPos: prefix.length + this.state.cursorCol,
|
||||
});
|
||||
} else {
|
||||
layoutLines.push({
|
||||
text: prefixedLine,
|
||||
hasCursor: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Line needs wrapping
|
||||
const chunks = [];
|
||||
for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) {
|
||||
chunks.push(prefixedLine.slice(pos, pos + maxLineLength));
|
||||
}
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||
const chunk = chunks[chunkIndex];
|
||||
if (!chunk) continue;
|
||||
|
||||
const chunkStart = chunkIndex * maxLineLength;
|
||||
const chunkEnd = chunkStart + chunk.length;
|
||||
const cursorPos = prefix.length + this.state.cursorCol;
|
||||
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
|
||||
|
||||
if (hasCursorInChunk) {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
hasCursor: true,
|
||||
cursorPos: cursorPos - chunkStart,
|
||||
});
|
||||
} else {
|
||||
layoutLines.push({
|
||||
text: chunk,
|
||||
hasCursor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layoutLines;
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.state.lines.join("\n");
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
// Split text into lines, handling different line endings
|
||||
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
|
||||
// Ensure at least one empty line
|
||||
this.state.lines = lines.length === 0 ? [""] : lines;
|
||||
|
||||
// Reset cursor to end of text
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
|
||||
|
||||
// Notify of change
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
// All the editor methods from before...
|
||||
private insertCharacter(char: string): void {
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = line.slice(0, this.state.cursorCol);
|
||||
const after = line.slice(this.state.cursorCol);
|
||||
|
||||
this.state.lines[this.state.cursorLine] = before + char + after;
|
||||
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
|
||||
// Check if we should trigger or update autocomplete
|
||||
if (!this.isAutocompleting) {
|
||||
// Auto-trigger for "/" at the start of a line (slash commands)
|
||||
if (char === "/" && this.isAtStartOfMessage()) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
// Also auto-trigger when typing letters in a slash command context
|
||||
else if (/[a-zA-Z0-9]/.test(char)) {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
// Check if we're in a slash command with a space (i.e., typing arguments)
|
||||
if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.updateAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
logger.debug("TextEditor", "Processing paste", {
|
||||
pastedText: JSON.stringify(pastedText),
|
||||
hasTab: pastedText.includes("\t"),
|
||||
tabCount: (pastedText.match(/\t/g) || []).length,
|
||||
});
|
||||
|
||||
// Clean the pasted text
|
||||
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Convert tabs to spaces (4 spaces per tab)
|
||||
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
||||
|
||||
// Filter out non-printable characters except newlines
|
||||
const filteredText = tabExpandedText
|
||||
.split("")
|
||||
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
|
||||
.join("");
|
||||
|
||||
// Split into lines
|
||||
const pastedLines = filteredText.split("\n");
|
||||
|
||||
if (pastedLines.length === 1) {
|
||||
// Single line - just insert each character
|
||||
const text = pastedLines[0] || "";
|
||||
for (const char of text) {
|
||||
this.insertCharacter(char);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-line paste - be very careful with array manipulation
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Build the new lines array step by step
|
||||
const newLines: string[] = [];
|
||||
|
||||
// Add all lines before current line
|
||||
for (let i = 0; i < this.state.cursorLine; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Add the first pasted line merged with before cursor text
|
||||
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
||||
|
||||
// Add all middle pasted lines
|
||||
for (let i = 1; i < pastedLines.length - 1; i++) {
|
||||
newLines.push(pastedLines[i] || "");
|
||||
}
|
||||
|
||||
// Add the last pasted line with after cursor text
|
||||
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
||||
|
||||
// Add all lines after current line
|
||||
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Replace the entire lines array
|
||||
this.state.lines = newLines;
|
||||
|
||||
// Update cursor position to end of pasted content
|
||||
this.state.cursorLine += pastedLines.length - 1;
|
||||
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
|
||||
|
||||
// Notify of change
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private addNewLine(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Split current line
|
||||
this.state.lines[this.state.cursorLine] = before;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
|
||||
|
||||
// Move cursor to start of new line
|
||||
this.state.cursorLine++;
|
||||
this.state.cursorCol = 0;
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackspace(): void {
|
||||
if (this.state.cursorCol > 0) {
|
||||
// Delete character in current line
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
const before = line.slice(0, this.state.cursorCol - 1);
|
||||
const after = line.slice(this.state.cursorCol);
|
||||
|
||||
this.state.lines[this.state.cursorLine] = before + after;
|
||||
this.state.cursorCol--;
|
||||
} else if (this.state.cursorLine > 0) {
|
||||
// Merge with previous line
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
||||
this.state.cursorLine--;
|
||||
this.state.cursorCol = previousLine.length;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
|
||||
// Update autocomplete after backspace
|
||||
if (this.isAutocompleting) {
|
||||
this.updateAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private moveToLineStart(): void {
|
||||
this.state.cursorCol = 0;
|
||||
}
|
||||
|
||||
private moveToLineEnd(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = currentLine.length;
|
||||
}
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
if (this.state.cursorCol < currentLine.length) {
|
||||
// Delete character at cursor position (forward delete)
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol + 1);
|
||||
this.state.lines[this.state.cursorLine] = before + after;
|
||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
// At end of line - merge with next line
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private deleteCurrentLine(): void {
|
||||
if (this.state.lines.length === 1) {
|
||||
// Only one line - just clear it
|
||||
this.state.lines[0] = "";
|
||||
this.state.cursorCol = 0;
|
||||
} else {
|
||||
// Multiple lines - remove current line
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
||||
// Adjust cursor position
|
||||
if (this.state.cursorLine >= this.state.lines.length) {
|
||||
// Was on last line, move to new last line
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
}
|
||||
|
||||
// Clamp cursor column to new line length
|
||||
const newLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private moveCursor(deltaLine: number, deltaCol: number): void {
|
||||
if (deltaLine !== 0) {
|
||||
const newLine = this.state.cursorLine + deltaLine;
|
||||
if (newLine >= 0 && newLine < this.state.lines.length) {
|
||||
this.state.cursorLine = newLine;
|
||||
// Clamp cursor column to new line length
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaCol !== 0) {
|
||||
// Move column
|
||||
const newCol = this.state.cursorCol + deltaCol;
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const maxCol = currentLine.length;
|
||||
this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if cursor is at start of message (for slash command detection)
|
||||
private isAtStartOfMessage(): boolean {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
// At start if line is empty, only contains whitespace, or is just "/"
|
||||
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
||||
}
|
||||
|
||||
// Autocomplete methods
|
||||
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
||||
logger.debug("TextEditor", "tryTriggerAutocomplete called", {
|
||||
explicitTab,
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if we should trigger file completion on Tab
|
||||
if (explicitTab) {
|
||||
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
||||
const shouldTrigger =
|
||||
!provider.shouldTriggerFileCompletion ||
|
||||
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
||||
|
||||
logger.debug("TextEditor", "Tab file completion check", {
|
||||
hasShouldTriggerMethod: !!provider.shouldTriggerFileCompletion,
|
||||
shouldTrigger,
|
||||
lines: this.state.lines,
|
||||
cursorLine: this.state.cursorLine,
|
||||
cursorCol: this.state.cursorCol,
|
||||
});
|
||||
|
||||
if (!shouldTrigger) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
logger.debug("TextEditor", "Autocomplete suggestions", {
|
||||
hasSuggestions: !!suggestions,
|
||||
itemCount: suggestions?.items.length || 0,
|
||||
prefix: suggestions?.prefix,
|
||||
});
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
this.isAutocompleting = true;
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handleTabCompletion(): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
// Check if we're in a slash command context
|
||||
if (beforeCursor.trimStart().startsWith("/")) {
|
||||
logger.debug("TextEditor", "Tab in slash command context", { beforeCursor });
|
||||
this.handleSlashCommandCompletion();
|
||||
} else {
|
||||
logger.debug("TextEditor", "Tab in file completion context", { beforeCursor });
|
||||
this.forceFileAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlashCommandCompletion(): void {
|
||||
// For now, fall back to regular autocomplete (slash commands)
|
||||
// This can be extended later to handle command-specific argument completion
|
||||
logger.debug("TextEditor", "Handling slash command completion");
|
||||
this.tryTriggerAutocomplete(true);
|
||||
}
|
||||
|
||||
private forceFileAutocomplete(): void {
|
||||
logger.debug("TextEditor", "forceFileAutocomplete called", {
|
||||
hasProvider: !!this.autocompleteProvider,
|
||||
});
|
||||
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if provider has the force method
|
||||
const provider = this.autocompleteProvider as any;
|
||||
if (!provider.getForceFileSuggestions) {
|
||||
logger.debug("TextEditor", "Provider doesn't support forced file completion, falling back to regular");
|
||||
this.tryTriggerAutocomplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = provider.getForceFileSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
logger.debug("TextEditor", "Forced file autocomplete suggestions", {
|
||||
hasSuggestions: !!suggestions,
|
||||
itemCount: suggestions?.items.length || 0,
|
||||
prefix: suggestions?.prefix,
|
||||
});
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
this.isAutocompleting = true;
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private cancelAutocomplete(): void {
|
||||
this.isAutocompleting = false;
|
||||
this.autocompleteList = undefined as any;
|
||||
this.autocompletePrefix = "";
|
||||
}
|
||||
|
||||
private updateAutocomplete(): void {
|
||||
if (!this.isAutocompleting || !this.autocompleteProvider) return;
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
if (this.autocompleteList) {
|
||||
// Update the existing list with new items
|
||||
this.autocompleteList = new SelectList(suggestions.items, 5);
|
||||
}
|
||||
} else {
|
||||
// No more matches, cancel autocomplete
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
473
packages/tui/src/tui.ts
Normal file
473
packages/tui/src/tui.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { writeSync } from "fs";
|
||||
import process from "process";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export interface Padding {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerRenderResult extends ComponentRenderResult {
|
||||
keepLines: number;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
render(width: number): ComponentRenderResult;
|
||||
handleInput?(keyData: string): void;
|
||||
}
|
||||
|
||||
// Sentinel component used to mark removed components - triggers cascade rendering
|
||||
class SentinelComponent implements Component {
|
||||
render(): ComponentRenderResult {
|
||||
return {
|
||||
lines: [],
|
||||
changed: true, // Always trigger cascade
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Base Container class that manages child components
|
||||
export class Container {
|
||||
protected children: Element[] = [];
|
||||
protected lines: string[] = [];
|
||||
protected parentTui: TUI | undefined; // Reference to parent TUI for triggering re-renders
|
||||
|
||||
constructor(parentTui?: TUI | undefined) {
|
||||
this.parentTui = parentTui;
|
||||
}
|
||||
|
||||
setParentTui(tui: TUI | undefined): void {
|
||||
this.parentTui = tui;
|
||||
}
|
||||
|
||||
addChild(component: Element): void {
|
||||
this.children.push(component);
|
||||
|
||||
// Set parent TUI reference for nested containers
|
||||
if (component instanceof Container && this.parentTui) {
|
||||
component.setParentTui(this.parentTui);
|
||||
}
|
||||
|
||||
if (this.parentTui) {
|
||||
this.parentTui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
removeChild(component: Element): void {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index >= 0) {
|
||||
// Replace with sentinel instead of splicing to maintain array structure
|
||||
this.children[index] = new SentinelComponent();
|
||||
// Keep the childTotalLines entry - sentinel will update it to 0
|
||||
|
||||
// Clear parent TUI reference for nested containers
|
||||
if (component instanceof Container) {
|
||||
component.setParentTui(undefined);
|
||||
}
|
||||
|
||||
// Use normal render - sentinel will trigger cascade naturally
|
||||
if (this.parentTui) {
|
||||
this.parentTui.requestRender();
|
||||
}
|
||||
} else {
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.removeChild(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeChildAt(index: number): void {
|
||||
if (index >= 0 && index < this.children.length) {
|
||||
const component = this.children[index];
|
||||
|
||||
// Replace with sentinel instead of splicing to maintain array structure
|
||||
this.children[index] = new SentinelComponent();
|
||||
|
||||
// Clear parent TUI reference for nested containers
|
||||
if (component instanceof Container) {
|
||||
component.setParentTui(undefined);
|
||||
}
|
||||
|
||||
// Use normal render - sentinel will trigger cascade naturally
|
||||
if (this.parentTui) {
|
||||
this.parentTui.requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): ContainerRenderResult {
|
||||
let keepLines = 0;
|
||||
let changed = false;
|
||||
const newLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < this.children.length; i++) {
|
||||
const child = this.children[i];
|
||||
if (!child) continue;
|
||||
|
||||
if (child instanceof Container) {
|
||||
const result = child.render(width);
|
||||
newLines.push(...result.lines);
|
||||
if (!changed && !result.changed) {
|
||||
keepLines += result.lines.length;
|
||||
} else {
|
||||
if (!changed) {
|
||||
// First change - use the child's keepLines
|
||||
changed = true;
|
||||
keepLines += result.keepLines;
|
||||
}
|
||||
// After first change, don't add any more keepLines
|
||||
}
|
||||
} else {
|
||||
const result = child.render(width);
|
||||
newLines.push(...result.lines);
|
||||
if (!changed && !result.changed) {
|
||||
keepLines += result.lines.length;
|
||||
} else {
|
||||
if (!changed) {
|
||||
// First change for a non-container component
|
||||
changed = true;
|
||||
}
|
||||
// After first change, don't add any more keepLines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lines = newLines;
|
||||
return {
|
||||
lines: this.lines,
|
||||
changed,
|
||||
keepLines,
|
||||
};
|
||||
}
|
||||
|
||||
// Get child for external manipulation
|
||||
// Get child at index
|
||||
// Note: This may return a SentinelComponent if a child was removed but not yet cleaned up
|
||||
getChild(index: number): Element | undefined {
|
||||
return this.children[index];
|
||||
}
|
||||
|
||||
// Get number of children
|
||||
// Note: This count includes sentinel components until they are cleaned up after the next render pass
|
||||
getChildCount(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
|
||||
// Clear all children from the container
|
||||
clear(): void {
|
||||
// Clear parent TUI references for nested containers
|
||||
for (const child of this.children) {
|
||||
if (child instanceof Container) {
|
||||
child.setParentTui(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the children array
|
||||
this.children = [];
|
||||
|
||||
// Request render if we have a parent TUI
|
||||
if (this.parentTui) {
|
||||
this.parentTui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up sentinel components
|
||||
cleanupSentinels(): void {
|
||||
const originalCount = this.children.length;
|
||||
const validChildren: Element[] = [];
|
||||
let sentinelCount = 0;
|
||||
|
||||
for (const child of this.children) {
|
||||
if (child && !(child instanceof SentinelComponent)) {
|
||||
validChildren.push(child);
|
||||
|
||||
// Recursively clean up nested containers
|
||||
if (child instanceof Container) {
|
||||
child.cleanupSentinels();
|
||||
}
|
||||
} else if (child instanceof SentinelComponent) {
|
||||
sentinelCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.children = validChildren;
|
||||
|
||||
if (sentinelCount > 0) {
|
||||
logger.debug("Container", "Cleaned up sentinels", {
|
||||
originalCount,
|
||||
newCount: this.children.length,
|
||||
sentinelsRemoved: sentinelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Element = Component | Container;
|
||||
|
||||
export class TUI extends Container {
|
||||
private focusedComponent: Component | null = null;
|
||||
private needsRender: boolean = false;
|
||||
private wasRaw: boolean = false;
|
||||
private totalLines: number = 0;
|
||||
private isFirstRender: boolean = true;
|
||||
private isStarted: boolean = false;
|
||||
public onGlobalKeyPress?: (data: string) => boolean;
|
||||
|
||||
constructor() {
|
||||
super(); // No parent TUI for root
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleKeypress = this.handleKeypress.bind(this);
|
||||
logger.componentLifecycle("TUI", "created");
|
||||
}
|
||||
|
||||
configureLogging(config: Parameters<typeof logger.configure>[0]): void {
|
||||
logger.configure(config);
|
||||
logger.info("TUI", "Logging configured", config);
|
||||
}
|
||||
|
||||
override addChild(component: Element): void {
|
||||
// Set parent TUI reference for containers
|
||||
if (component instanceof Container) {
|
||||
component.setParentTui(this);
|
||||
}
|
||||
super.addChild(component);
|
||||
|
||||
// Only auto-render if TUI has been started
|
||||
if (this.isStarted) {
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
override removeChild(component: Element): void {
|
||||
super.removeChild(component);
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
setFocus(component: Component): void {
|
||||
// Check if component exists anywhere in the hierarchy
|
||||
if (this.findComponent(component)) {
|
||||
this.focusedComponent = component;
|
||||
}
|
||||
}
|
||||
|
||||
private findComponent(component: Component): boolean {
|
||||
// Check direct children
|
||||
if (this.children.includes(component)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursively search in containers
|
||||
for (const comp of this.children) {
|
||||
if (comp instanceof Container) {
|
||||
if (this.findInContainer(comp, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private findInContainer(container: Container, component: Component): boolean {
|
||||
const childCount = container.getChildCount();
|
||||
|
||||
// Check direct children
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (child === component) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively search in nested containers
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const child = container.getChild(i);
|
||||
if (child instanceof Container) {
|
||||
if (this.findInContainer(child, component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
requestRender(): void {
|
||||
if (!this.isStarted) return;
|
||||
this.needsRender = true;
|
||||
// Batch renders on next tick
|
||||
process.nextTick(() => {
|
||||
if (this.needsRender) {
|
||||
this.renderToScreen();
|
||||
this.needsRender = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Set started flag
|
||||
this.isStarted = true;
|
||||
|
||||
// Hide the terminal cursor
|
||||
process.stdout.write("\x1b[?25l");
|
||||
|
||||
// Set up raw mode for key capture
|
||||
try {
|
||||
this.wasRaw = process.stdin.isRaw || false;
|
||||
if (process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
|
||||
// Listen for events
|
||||
process.stdout.on("resize", this.handleResize);
|
||||
process.stdin.on("data", this.handleKeypress);
|
||||
} catch (error) {
|
||||
console.error("Error setting up raw mode:", error);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
this.renderToScreen();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Show the terminal cursor again
|
||||
process.stdout.write("\x1b[?25h");
|
||||
|
||||
process.stdin.removeListener("data", this.handleKeypress);
|
||||
process.stdout.removeListener("resize", this.handleResize);
|
||||
if (process.stdin.setRawMode) {
|
||||
process.stdin.setRawMode(this.wasRaw);
|
||||
}
|
||||
}
|
||||
|
||||
private renderToScreen(resize: boolean = false): void {
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
|
||||
logger.debug("TUI", "Starting render cycle", {
|
||||
termWidth,
|
||||
componentCount: this.children.length,
|
||||
isFirstRender: this.isFirstRender,
|
||||
});
|
||||
|
||||
const result = this.render(termWidth);
|
||||
|
||||
if (resize) {
|
||||
this.totalLines = result.lines.length;
|
||||
result.keepLines = 0;
|
||||
this.isFirstRender = true;
|
||||
}
|
||||
|
||||
logger.debug("TUI", "Render result", {
|
||||
totalLines: result.lines.length,
|
||||
keepLines: result.keepLines,
|
||||
changed: result.changed,
|
||||
previousTotalLines: this.totalLines,
|
||||
});
|
||||
|
||||
if (!result.changed) {
|
||||
// Nothing changed - skip render
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle cursor positioning
|
||||
if (this.isFirstRender) {
|
||||
// First render: just append to current terminal position
|
||||
this.isFirstRender = false;
|
||||
// Output all lines normally on first render
|
||||
for (const line of result.lines) {
|
||||
console.log(line);
|
||||
}
|
||||
} else {
|
||||
// Move cursor up to start of changing content and clear down
|
||||
const linesToMoveUp = this.totalLines - result.keepLines;
|
||||
let output = "";
|
||||
|
||||
logger.debug("TUI", "Cursor movement", {
|
||||
linesToMoveUp,
|
||||
totalLines: this.totalLines,
|
||||
keepLines: result.keepLines,
|
||||
changingLineCount: result.lines.length - result.keepLines,
|
||||
});
|
||||
|
||||
if (linesToMoveUp > 0) {
|
||||
output += `\x1b[${linesToMoveUp}A\x1b[0J`;
|
||||
}
|
||||
|
||||
// Build the output string for all changing lines
|
||||
const changingLines = result.lines.slice(result.keepLines);
|
||||
|
||||
logger.debug("TUI", "Output details", {
|
||||
linesToMoveUp,
|
||||
changingLinesCount: changingLines.length,
|
||||
keepLines: result.keepLines,
|
||||
totalLines: result.lines.length,
|
||||
previousTotalLines: this.totalLines,
|
||||
});
|
||||
for (const line of changingLines) {
|
||||
output += `${line}\n`;
|
||||
}
|
||||
|
||||
// Write everything at once - use synchronous write to prevent race conditions
|
||||
writeSync(process.stdout.fd, output);
|
||||
}
|
||||
|
||||
this.totalLines = result.lines.length;
|
||||
|
||||
// Clean up sentinels after rendering
|
||||
this.cleanupSentinels();
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
// Clear screen, hide cursor, and reset color
|
||||
process.stdout.write("\u001Bc\x1b[?25l\u001B[3J");
|
||||
|
||||
// Terminal size changed - force re-render all
|
||||
this.renderToScreen(true);
|
||||
}
|
||||
|
||||
private handleKeypress(data: string): void {
|
||||
logger.keyInput("TUI", data);
|
||||
|
||||
// Don't handle Ctrl+C here - let the global key handler deal with it
|
||||
// if (data.charCodeAt(0) === 3) {
|
||||
// logger.info("TUI", "Ctrl+C received");
|
||||
// return; // Don't process this key further
|
||||
// }
|
||||
|
||||
// Call global key handler if set
|
||||
if (this.onGlobalKeyPress) {
|
||||
const shouldForward = this.onGlobalKeyPress(data);
|
||||
if (!shouldForward) {
|
||||
// Global handler consumed the key, don't forward to focused component
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send input to focused component
|
||||
if (this.focusedComponent?.handleInput) {
|
||||
logger.debug("TUI", "Forwarding input to focused component", {
|
||||
componentType: this.focusedComponent.constructor.name,
|
||||
});
|
||||
this.focusedComponent.handleInput(data);
|
||||
// Trigger re-render after input
|
||||
this.requestRender();
|
||||
} else {
|
||||
logger.warn("TUI", "No focused component to handle input", {
|
||||
focusedComponent: this.focusedComponent?.constructor.name || "none",
|
||||
hasHandleInput: this.focusedComponent?.handleInput ? "yes" : "no",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/tui/src/whitespace-component.ts
Normal file
24
packages/tui/src/whitespace-component.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Component, ComponentRenderResult } from "./tui.js";
|
||||
|
||||
/**
|
||||
* A simple component that renders blank lines for spacing
|
||||
*/
|
||||
export class WhitespaceComponent implements Component {
|
||||
private lines: string[] = [];
|
||||
private lineCount: number;
|
||||
private firstRender: boolean = true;
|
||||
|
||||
constructor(lineCount: number = 1) {
|
||||
this.lineCount = Math.max(0, lineCount); // Ensure non-negative
|
||||
this.lines = new Array(this.lineCount).fill("");
|
||||
}
|
||||
|
||||
render(_width: number): ComponentRenderResult {
|
||||
const result = {
|
||||
lines: this.lines,
|
||||
changed: this.firstRender,
|
||||
};
|
||||
this.firstRender = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
98
packages/tui/test/demo.ts
Normal file
98
packages/tui/test/demo.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
Container,
|
||||
MarkdownComponent,
|
||||
TextComponent,
|
||||
TextEditor,
|
||||
TUI,
|
||||
} from "../src/index.js";
|
||||
|
||||
// Create TUI manager
|
||||
const ui = new TUI();
|
||||
ui.configureLogging({
|
||||
enabled: true,
|
||||
logLevel: "debug",
|
||||
logFile: "tui-debug.log",
|
||||
});
|
||||
|
||||
// Create a chat container that will hold messages
|
||||
const chatContainer = new Container();
|
||||
const editor = new TextEditor();
|
||||
|
||||
// Set up autocomplete with slash commands
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[
|
||||
{ name: "clear", description: "Clear chat history" },
|
||||
{ name: "clear-last", description: "Clear last message" },
|
||||
{ name: "exit", description: "Exit the application" },
|
||||
],
|
||||
process.cwd(),
|
||||
);
|
||||
editor.setAutocompleteProvider(autocompleteProvider);
|
||||
|
||||
// Add components to UI
|
||||
ui.addChild(new TextComponent("Differential Rendering TUI"));
|
||||
ui.addChild(chatContainer);
|
||||
ui.addChild(editor);
|
||||
|
||||
// Set focus to the editor (index 2)
|
||||
ui.setFocus(editor);
|
||||
|
||||
// Test with Claude's multiline text
|
||||
const testText = `Root level:
|
||||
- CLAUDE.md
|
||||
- README.md
|
||||
- biome.json
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- tsconfig.json
|
||||
- tui-debug.log
|
||||
|
||||
Directories:
|
||||
- \`data/\` (JSON test files)
|
||||
- \`dist/\`
|
||||
- \`docs/\` (markdown documentation)
|
||||
- \`node_modules/\`
|
||||
- \`src/\` (TypeScript source files)`;
|
||||
|
||||
// Pre-fill the editor with the test text
|
||||
editor.setText(testText);
|
||||
|
||||
// Handle editor submissions
|
||||
editor.onSubmit = (text: string) => {
|
||||
text = text.trim();
|
||||
|
||||
if (text === "/clear") {
|
||||
chatContainer.clear();
|
||||
ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/clear-last") {
|
||||
const count = chatContainer.getChildCount();
|
||||
if (count > 0) {
|
||||
chatContainer.removeChildAt(count - 1);
|
||||
ui.requestRender();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/exit") {
|
||||
ui.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
// Create new message component and add to chat container
|
||||
const message = new MarkdownComponent(text);
|
||||
chatContainer.addChild(message);
|
||||
|
||||
// Manually trigger re-render
|
||||
ui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
// Start the UI
|
||||
ui.start();
|
||||
9
packages/tui/tsconfig.build.json
Normal file
9
packages/tui/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue