zerg-ztc 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +27 -8
- package/dist/App.js.map +1 -1
- package/dist/agent/agent.d.ts +4 -0
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +21 -3
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/tools/file.d.ts.map +1 -1
- package/dist/agent/tools/file.js +10 -6
- package/dist/agent/tools/file.js.map +1 -1
- package/dist/agent/tools/index.d.ts +2 -2
- package/dist/agent/tools/index.d.ts.map +1 -1
- package/dist/agent/tools/index.js +2 -2
- package/dist/agent/tools/index.js.map +1 -1
- package/dist/agent/tools/search.d.ts.map +1 -1
- package/dist/agent/tools/search.js +5 -4
- package/dist/agent/tools/search.js.map +1 -1
- package/dist/agent/tools/shell.d.ts.map +1 -1
- package/dist/agent/tools/shell.js +7 -3
- package/dist/agent/tools/shell.js.map +1 -1
- package/dist/agent/tools/types.d.ts +4 -1
- package/dist/agent/tools/types.d.ts.map +1 -1
- package/dist/components/InputArea.d.ts +1 -0
- package/dist/components/InputArea.d.ts.map +1 -1
- package/dist/components/InputArea.js +59 -1
- package/dist/components/InputArea.js.map +1 -1
- package/dist/utils/path_complete.d.ts +13 -0
- package/dist/utils/path_complete.d.ts.map +1 -0
- package/dist/utils/path_complete.js +137 -0
- package/dist/utils/path_complete.js.map +1 -0
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +9 -1
- package/dist/utils/shell.js.map +1 -1
- package/package.json +1 -1
- package/src/App.tsx +26 -7
- package/src/agent/agent.ts +26 -6
- package/src/agent/tools/file.ts +24 -19
- package/src/agent/tools/index.ts +5 -4
- package/src/agent/tools/search.ts +6 -5
- package/src/agent/tools/shell.ts +13 -9
- package/src/agent/tools/types.ts +5 -1
- package/src/components/InputArea.tsx +70 -3
- package/src/utils/path_complete.ts +147 -0
- package/src/utils/shell.ts +10 -1
package/src/agent/agent.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface AgentConfig {
|
|
|
22
22
|
backend?: AgentBackend;
|
|
23
23
|
policy?: Policy;
|
|
24
24
|
tracer?: Tracer;
|
|
25
|
+
cwd?: string; // Working directory for tool execution
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export class AgentError extends Error {
|
|
@@ -42,10 +43,11 @@ export class Agent {
|
|
|
42
43
|
private policy: Policy;
|
|
43
44
|
private tracer: Tracer;
|
|
44
45
|
private streamChunkSize = 32;
|
|
45
|
-
|
|
46
|
+
private _cwd: string;
|
|
47
|
+
|
|
46
48
|
constructor(config: AgentConfig = {}) {
|
|
47
49
|
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY || '';
|
|
48
|
-
|
|
50
|
+
|
|
49
51
|
this.config = {
|
|
50
52
|
model: config.model || 'claude-opus-4-20250514',
|
|
51
53
|
apiKey,
|
|
@@ -56,18 +58,28 @@ export class Agent {
|
|
|
56
58
|
systemPrompt: config.systemPrompt || this.getDefaultSystemPrompt(),
|
|
57
59
|
backend: config.backend || new AnthropicBackend({ apiKey, apiEndpoint: config.apiEndpoint }),
|
|
58
60
|
policy: config.policy || new AllowAllPolicy(),
|
|
59
|
-
tracer: config.tracer || new NoopTracer()
|
|
61
|
+
tracer: config.tracer || new NoopTracer(),
|
|
62
|
+
cwd: config.cwd || process.cwd()
|
|
60
63
|
};
|
|
61
64
|
|
|
62
65
|
this.backend = this.config.backend;
|
|
63
66
|
this.policy = this.config.policy;
|
|
64
67
|
this.tracer = this.config.tracer;
|
|
68
|
+
this._cwd = this.config.cwd;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
hasApiKey(): boolean {
|
|
68
72
|
return !!this.config.apiKey && this.config.apiKey.length > 0;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
setCwd(cwd: string): void {
|
|
76
|
+
this._cwd = cwd;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCwd(): string {
|
|
80
|
+
return this._cwd;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
private getDefaultSystemPrompt(): string {
|
|
72
84
|
return `You are ZTC (Zerg Terminal Client), an AI assistant that helps users interact with the Zerg continual AI system and manage local development tasks.
|
|
73
85
|
|
|
@@ -97,7 +109,14 @@ When a user intent maps to an available slash command, invoke the command direct
|
|
|
97
109
|
// Convert messages to API format
|
|
98
110
|
private formatMessages(messages: Message[]): LlmMessage[] {
|
|
99
111
|
return messages
|
|
100
|
-
.filter((m): m is Message & { role: 'user' | 'assistant' } =>
|
|
112
|
+
.filter((m): m is Message & { role: 'user' | 'assistant' } => {
|
|
113
|
+
// Only include user and assistant messages
|
|
114
|
+
if (m.role !== 'user' && m.role !== 'assistant') return false;
|
|
115
|
+
// Filter out assistant messages with empty content (from tool-only responses)
|
|
116
|
+
// The API rejects empty content for non-final assistant messages
|
|
117
|
+
if (m.role === 'assistant' && (!m.content || m.content.trim() === '')) return false;
|
|
118
|
+
return true;
|
|
119
|
+
})
|
|
101
120
|
.map(m => ({
|
|
102
121
|
role: m.role,
|
|
103
122
|
content: m.role === 'user' ? this.buildContentBlocks(m.content) : m.content
|
|
@@ -336,9 +355,10 @@ When a user intent maps to an available slash command, invoke the command direct
|
|
|
336
355
|
|
|
337
356
|
try {
|
|
338
357
|
const result = await executeTool(
|
|
339
|
-
toolBlock.name,
|
|
358
|
+
toolBlock.name,
|
|
340
359
|
toolBlock.input,
|
|
341
|
-
this.config.tools
|
|
360
|
+
this.config.tools,
|
|
361
|
+
{ cwd: this._cwd }
|
|
342
362
|
);
|
|
343
363
|
|
|
344
364
|
toolCall.status = 'complete';
|
package/src/agent/tools/file.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { readFile, writeFile, readdir, stat } from 'fs/promises';
|
|
2
2
|
import { dirname, resolve } from 'path';
|
|
3
|
-
import { Tool } from './types.js';
|
|
3
|
+
import { Tool, ToolContext } from './types.js';
|
|
4
4
|
import { ToolCapability } from '../runtime/capabilities.js';
|
|
5
5
|
import { buildSimpleDiff } from '../../utils/diff.js';
|
|
6
6
|
|
|
7
|
+
function resolvePath(path: string, context?: ToolContext): string {
|
|
8
|
+
const base = context?.cwd || process.cwd();
|
|
9
|
+
return resolve(base, path);
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
// --- File Tools ---
|
|
8
13
|
|
|
9
14
|
export const readFileTool: Tool = {
|
|
@@ -27,10 +32,10 @@ export const readFileTool: Tool = {
|
|
|
27
32
|
required: ['path']
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
|
-
execute: async (args) => {
|
|
31
|
-
const path =
|
|
35
|
+
execute: async (args, context) => {
|
|
36
|
+
const path = resolvePath(String(args.path), context);
|
|
32
37
|
const encoding = (args.encoding as BufferEncoding) || 'utf-8';
|
|
33
|
-
|
|
38
|
+
|
|
34
39
|
try {
|
|
35
40
|
const content = await readFile(path, { encoding });
|
|
36
41
|
const stats = await stat(path);
|
|
@@ -70,16 +75,16 @@ export const writeFileTool: Tool = {
|
|
|
70
75
|
required: ['path', 'content']
|
|
71
76
|
}
|
|
72
77
|
},
|
|
73
|
-
execute: async (args) => {
|
|
74
|
-
const path =
|
|
78
|
+
execute: async (args, context) => {
|
|
79
|
+
const path = resolvePath(String(args.path), context);
|
|
75
80
|
const content = String(args.content);
|
|
76
81
|
const append = args.append === 'true';
|
|
77
|
-
|
|
82
|
+
|
|
78
83
|
try {
|
|
79
84
|
// Ensure directory exists
|
|
80
85
|
const { mkdir, appendFile } = await import('fs/promises');
|
|
81
86
|
await mkdir(dirname(path), { recursive: true });
|
|
82
|
-
|
|
87
|
+
|
|
83
88
|
let beforeContent = '';
|
|
84
89
|
try {
|
|
85
90
|
beforeContent = await readFile(path, { encoding: 'utf-8' });
|
|
@@ -92,7 +97,7 @@ export const writeFileTool: Tool = {
|
|
|
92
97
|
} else {
|
|
93
98
|
await writeFile(path, content, 'utf-8');
|
|
94
99
|
}
|
|
95
|
-
|
|
100
|
+
|
|
96
101
|
const stats = await stat(path);
|
|
97
102
|
const afterContent = await readFile(path, { encoding: 'utf-8' });
|
|
98
103
|
const diff = buildSimpleDiff(beforeContent, afterContent);
|
|
@@ -131,38 +136,38 @@ export const listDirectoryTool: Tool = {
|
|
|
131
136
|
required: ['path']
|
|
132
137
|
}
|
|
133
138
|
},
|
|
134
|
-
execute: async (args) => {
|
|
135
|
-
const path =
|
|
139
|
+
execute: async (args, context) => {
|
|
140
|
+
const path = resolvePath(String(args.path), context);
|
|
136
141
|
const recursive = args.recursive === 'true';
|
|
137
|
-
|
|
142
|
+
|
|
138
143
|
async function listDir(dir: string, depth = 0): Promise<object[]> {
|
|
139
144
|
if (depth > 3) return [];
|
|
140
|
-
|
|
145
|
+
|
|
141
146
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
142
147
|
const results: object[] = [];
|
|
143
|
-
|
|
148
|
+
|
|
144
149
|
for (const entry of entries.slice(0, 100)) { // Limit entries
|
|
145
150
|
const entryPath = resolve(dir, entry.name);
|
|
146
151
|
const info: Record<string, unknown> = {
|
|
147
152
|
name: entry.name,
|
|
148
153
|
type: entry.isDirectory() ? 'directory' : 'file'
|
|
149
154
|
};
|
|
150
|
-
|
|
155
|
+
|
|
151
156
|
if (entry.isFile()) {
|
|
152
157
|
const s = await stat(entryPath);
|
|
153
158
|
info.size = s.size;
|
|
154
159
|
}
|
|
155
|
-
|
|
160
|
+
|
|
156
161
|
if (recursive && entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
157
162
|
info.children = await listDir(entryPath, depth + 1);
|
|
158
163
|
}
|
|
159
|
-
|
|
164
|
+
|
|
160
165
|
results.push(info);
|
|
161
166
|
}
|
|
162
|
-
|
|
167
|
+
|
|
163
168
|
return results;
|
|
164
169
|
}
|
|
165
|
-
|
|
170
|
+
|
|
166
171
|
try {
|
|
167
172
|
const entries = await listDir(path);
|
|
168
173
|
return JSON.stringify({ path, entries });
|
package/src/agent/tools/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Tool } from './types.js';
|
|
1
|
+
import { Tool, ToolContext } from './types.js';
|
|
2
2
|
import { ToolDefinition } from '../../types.js';
|
|
3
3
|
import { readFileTool, writeFileTool, listDirectoryTool } from './file.js';
|
|
4
4
|
import { runCommandTool } from './shell.js';
|
|
@@ -27,15 +27,16 @@ export function getTool(name: string, tools: Tool[] = defaultTools): Tool | unde
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function executeTool(
|
|
30
|
-
name: string,
|
|
30
|
+
name: string,
|
|
31
31
|
args: Record<string, unknown>,
|
|
32
|
-
tools: Tool[] = defaultTools
|
|
32
|
+
tools: Tool[] = defaultTools,
|
|
33
|
+
context?: ToolContext
|
|
33
34
|
): Promise<string> {
|
|
34
35
|
const tool = getTool(name, tools);
|
|
35
36
|
if (!tool) {
|
|
36
37
|
throw new Error(`Unknown tool: ${name}`);
|
|
37
38
|
}
|
|
38
|
-
return tool.execute(args);
|
|
39
|
+
return tool.execute(args, context);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export { readFileTool, writeFileTool, listDirectoryTool } from './file.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
|
-
import { Tool } from './types.js';
|
|
3
|
+
import { Tool, ToolContext } from './types.js';
|
|
4
4
|
import { ToolCapability } from '../runtime/capabilities.js';
|
|
5
5
|
|
|
6
6
|
interface RunResult {
|
|
@@ -58,9 +58,10 @@ export const searchTool: Tool = {
|
|
|
58
58
|
required: ['pattern']
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
|
-
execute: async (args) => {
|
|
61
|
+
execute: async (args, context) => {
|
|
62
|
+
const baseCwd = context?.cwd || process.cwd();
|
|
62
63
|
const pattern = String(args.pattern || '');
|
|
63
|
-
const path = resolve(String(args.path || '.'));
|
|
64
|
+
const path = resolve(baseCwd, String(args.path || '.'));
|
|
64
65
|
const glob = args.glob ? String(args.glob) : undefined;
|
|
65
66
|
const outputMode = args.output_mode === 'files' ? 'files' : 'content';
|
|
66
67
|
const maxResults = Math.max(1, Math.min(200, parseInt(String(args.max_results || '20'), 10)));
|
|
@@ -82,14 +83,14 @@ export const searchTool: Tool = {
|
|
|
82
83
|
|
|
83
84
|
let result: RunResult | null = null;
|
|
84
85
|
try {
|
|
85
|
-
result = await runCommand('rg', rgArgs,
|
|
86
|
+
result = await runCommand('rg', rgArgs, baseCwd);
|
|
86
87
|
} catch {
|
|
87
88
|
result = null;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
if (!result || (result.code !== 0 && result.code !== 1)) {
|
|
91
92
|
const grepArgs = ['-R', '-n', pattern, path];
|
|
92
|
-
const fallback = await runCommand('grep', grepArgs,
|
|
93
|
+
const fallback = await runCommand('grep', grepArgs, baseCwd);
|
|
93
94
|
if (fallback.code !== 0 && fallback.code !== 1) {
|
|
94
95
|
throw new Error(fallback.stderr || 'Search failed');
|
|
95
96
|
}
|
package/src/agent/tools/shell.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { exec } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
|
-
import { Tool } from './types.js';
|
|
4
|
+
import { Tool, ToolContext } from './types.js';
|
|
5
5
|
import { ToolCapability } from '../runtime/capabilities.js';
|
|
6
6
|
|
|
7
7
|
const execAsync = promisify(exec);
|
|
@@ -32,24 +32,25 @@ export const runCommandTool: Tool = {
|
|
|
32
32
|
required: ['command']
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
|
-
execute: async (args) => {
|
|
35
|
+
execute: async (args, context) => {
|
|
36
36
|
const command = String(args.command);
|
|
37
|
-
const
|
|
37
|
+
const baseCwd = context?.cwd || process.cwd();
|
|
38
|
+
const cwd = args.cwd ? resolve(baseCwd, String(args.cwd)) : baseCwd;
|
|
38
39
|
const timeout = parseInt(String(args.timeout || '30000'), 10);
|
|
39
|
-
|
|
40
|
+
|
|
40
41
|
// Basic safety checks
|
|
41
42
|
const dangerous = ['rm -rf /', 'mkfs', ':(){:|:&};:'];
|
|
42
43
|
if (dangerous.some(d => command.includes(d))) {
|
|
43
44
|
throw new Error('Command rejected for safety reasons');
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
try {
|
|
47
|
-
const { stdout, stderr } = await execAsync(command, {
|
|
48
|
-
cwd,
|
|
48
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
49
|
+
cwd,
|
|
49
50
|
timeout,
|
|
50
51
|
maxBuffer: 1024 * 1024 // 1MB
|
|
51
52
|
});
|
|
52
|
-
|
|
53
|
+
|
|
53
54
|
return JSON.stringify({
|
|
54
55
|
command,
|
|
55
56
|
cwd,
|
|
@@ -59,7 +60,10 @@ export const runCommandTool: Tool = {
|
|
|
59
60
|
});
|
|
60
61
|
} catch (err) {
|
|
61
62
|
const e = err as Error & { stdout?: string; stderr?: string; code?: number };
|
|
62
|
-
|
|
63
|
+
// Include both stdout and stderr in error - many tools output errors to stdout
|
|
64
|
+
const output = [e.stdout, e.stderr].filter(Boolean).join('\n').trim();
|
|
65
|
+
const outputSnippet = output.slice(0, 2000) || '(no output)';
|
|
66
|
+
throw new Error(`Command failed (exit ${e.code || '?'}):\n${outputSnippet}`);
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
};
|
package/src/agent/tools/types.ts
CHANGED
|
@@ -3,8 +3,12 @@ import { ToolCapability } from '../runtime/capabilities.js';
|
|
|
3
3
|
|
|
4
4
|
// --- Tool Interface ---
|
|
5
5
|
|
|
6
|
+
export interface ToolContext {
|
|
7
|
+
cwd: string; // Working directory for path resolution
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
export interface Tool {
|
|
7
11
|
definition: ToolDefinition;
|
|
8
12
|
capabilities?: ToolCapability[];
|
|
9
|
-
execute: (args: Record<string, unknown
|
|
13
|
+
execute: (args: Record<string, unknown>, context?: ToolContext) => Promise<string>;
|
|
10
14
|
}
|
|
@@ -8,6 +8,7 @@ import { debugLog } from '../debug/logger.js';
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { saveClipboardImage } from '../utils/clipboard_image.js';
|
|
10
10
|
import { renderImagePreview } from '../utils/image_preview.js';
|
|
11
|
+
import { completePath } from '../utils/path_complete.js';
|
|
11
12
|
import {
|
|
12
13
|
createEmptyState,
|
|
13
14
|
insertText,
|
|
@@ -40,6 +41,7 @@ interface InputAreaProps {
|
|
|
40
41
|
placeholder?: string;
|
|
41
42
|
historyEnabled?: boolean;
|
|
42
43
|
debug?: boolean;
|
|
44
|
+
cwd?: string; // Working directory for path tab completion
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export type { InputState } from '../ui/core/input_state.js';
|
|
@@ -100,8 +102,8 @@ function reducer(state: InputState, action: InputAction): InputState {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
export const InputArea: React.FC<InputAreaProps> = ({
|
|
104
|
-
onSubmit,
|
|
105
|
+
export const InputArea: React.FC<InputAreaProps> = ({
|
|
106
|
+
onSubmit,
|
|
105
107
|
onCommand,
|
|
106
108
|
commands = [],
|
|
107
109
|
onStateChange,
|
|
@@ -111,7 +113,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
|
|
111
113
|
disabled = false,
|
|
112
114
|
placeholder = 'Type a message...',
|
|
113
115
|
historyEnabled = true,
|
|
114
|
-
debug = false
|
|
116
|
+
debug = false,
|
|
117
|
+
cwd = process.cwd()
|
|
115
118
|
}) => {
|
|
116
119
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
117
120
|
const stateRef = React.useRef(state);
|
|
@@ -279,6 +282,60 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
|
|
279
282
|
return { ...current, segments, cursor: { index: cursor.index, offset: replacedLeft.length }, historyIdx: -1 };
|
|
280
283
|
}, []);
|
|
281
284
|
|
|
285
|
+
// Tab completion state
|
|
286
|
+
const tabCompletionAlternativesRef = React.useRef<string[]>([]);
|
|
287
|
+
const lastTabPrefixRef = React.useRef<string>('');
|
|
288
|
+
|
|
289
|
+
// Handle tab completion for shell commands
|
|
290
|
+
const handleTabComplete = useCallback(async (current: InputState): Promise<InputState> => {
|
|
291
|
+
const plainText = getPlainText(current.segments);
|
|
292
|
+
|
|
293
|
+
// Only complete if this looks like a shell command with a path
|
|
294
|
+
// Match: !cd path, !ls path, !cat path, etc.
|
|
295
|
+
const shellMatch = plainText.match(/^!(\w+)\s+(.*)$/);
|
|
296
|
+
if (!shellMatch) {
|
|
297
|
+
// Also support just !cd without a path yet
|
|
298
|
+
if (plainText.match(/^!cd\s*$/)) {
|
|
299
|
+
// Show home directory
|
|
300
|
+
const result = await completePath('~/', cwd);
|
|
301
|
+
if (result && result.alternatives.length > 0) {
|
|
302
|
+
tabCompletionAlternativesRef.current = result.alternatives;
|
|
303
|
+
onToast?.(`Completions: ${result.alternatives.slice(0, 5).join(', ')}${result.alternatives.length > 5 ? '...' : ''}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return current;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const partialPath = shellMatch[2];
|
|
310
|
+
const commandPrefix = `!${shellMatch[1]} `;
|
|
311
|
+
|
|
312
|
+
// Try to complete the path
|
|
313
|
+
const result = await completePath(partialPath, cwd);
|
|
314
|
+
if (!result) {
|
|
315
|
+
onToast?.('No completions');
|
|
316
|
+
return current;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// If we have alternatives and this is a repeat tab, show them
|
|
320
|
+
if (result.alternatives.length > 1) {
|
|
321
|
+
tabCompletionAlternativesRef.current = result.alternatives;
|
|
322
|
+
lastTabPrefixRef.current = partialPath;
|
|
323
|
+
onToast?.(`Completions: ${result.alternatives.slice(0, 6).join(' ')}${result.alternatives.length > 6 ? ' ...' : ''}`);
|
|
324
|
+
} else {
|
|
325
|
+
tabCompletionAlternativesRef.current = [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Update the input with the completed path
|
|
329
|
+
const newText = commandPrefix + result.completed;
|
|
330
|
+
const newSegments: InputSegment[] = [{ type: 'text', text: newText }];
|
|
331
|
+
return {
|
|
332
|
+
...current,
|
|
333
|
+
segments: newSegments,
|
|
334
|
+
cursor: { index: 0, offset: newText.length },
|
|
335
|
+
historyIdx: -1
|
|
336
|
+
};
|
|
337
|
+
}, [cwd, onToast]);
|
|
338
|
+
|
|
282
339
|
const inputRenderCount = React.useRef(0);
|
|
283
340
|
React.useEffect(() => {
|
|
284
341
|
inputRenderCount.current += 1;
|
|
@@ -599,6 +656,16 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
|
|
599
656
|
return;
|
|
600
657
|
}
|
|
601
658
|
|
|
659
|
+
// Tab completion for shell commands
|
|
660
|
+
if (safeKey.tab || input === '\t') {
|
|
661
|
+
void handleTabComplete(state).then(nextState => {
|
|
662
|
+
if (nextState !== state) {
|
|
663
|
+
dispatch({ type: 'apply', state: nextState });
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
602
669
|
if (key.leftArrow) {
|
|
603
670
|
if (key.ctrl) {
|
|
604
671
|
dispatch({ type: 'apply', state: moveWordLeft(state) });
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { resolve, dirname, basename } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get path completions for tab autocomplete
|
|
7
|
+
*/
|
|
8
|
+
export async function getPathCompletions(
|
|
9
|
+
partial: string,
|
|
10
|
+
cwd: string
|
|
11
|
+
): Promise<string[]> {
|
|
12
|
+
// Expand tilde
|
|
13
|
+
let expandedPartial = partial;
|
|
14
|
+
if (partial.startsWith('~/')) {
|
|
15
|
+
expandedPartial = resolve(homedir(), partial.slice(2));
|
|
16
|
+
} else if (partial === '~') {
|
|
17
|
+
expandedPartial = homedir();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Resolve the path relative to cwd
|
|
21
|
+
const fullPath = resolve(cwd, expandedPartial);
|
|
22
|
+
|
|
23
|
+
// Determine the directory to list and the prefix to match
|
|
24
|
+
let dirToList: string;
|
|
25
|
+
let prefix: string;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { stat } = await import('fs/promises');
|
|
29
|
+
const stats = await stat(fullPath);
|
|
30
|
+
if (stats.isDirectory()) {
|
|
31
|
+
// If it's a directory, list its contents
|
|
32
|
+
dirToList = fullPath;
|
|
33
|
+
prefix = '';
|
|
34
|
+
} else {
|
|
35
|
+
// It's a file, no completions
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Path doesn't exist, so complete the last component
|
|
40
|
+
dirToList = dirname(fullPath);
|
|
41
|
+
prefix = basename(fullPath).toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const entries = await readdir(dirToList, { withFileTypes: true });
|
|
46
|
+
const matches = entries
|
|
47
|
+
.filter(entry => entry.name.toLowerCase().startsWith(prefix))
|
|
48
|
+
.map(entry => {
|
|
49
|
+
const name = entry.name;
|
|
50
|
+
const suffix = entry.isDirectory() ? '/' : '';
|
|
51
|
+
return name + suffix;
|
|
52
|
+
})
|
|
53
|
+
.sort();
|
|
54
|
+
|
|
55
|
+
return matches;
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Complete a partial path, returning the completed path or null if no unique completion
|
|
63
|
+
*/
|
|
64
|
+
export async function completePath(
|
|
65
|
+
partial: string,
|
|
66
|
+
cwd: string
|
|
67
|
+
): Promise<{ completed: string; isDirectory: boolean; alternatives: string[] } | null> {
|
|
68
|
+
// Expand tilde for internal processing
|
|
69
|
+
let expandedPartial = partial;
|
|
70
|
+
let tildePrefix = '';
|
|
71
|
+
if (partial.startsWith('~/')) {
|
|
72
|
+
expandedPartial = resolve(homedir(), partial.slice(2));
|
|
73
|
+
tildePrefix = '~/';
|
|
74
|
+
} else if (partial === '~') {
|
|
75
|
+
return { completed: '~/', isDirectory: true, alternatives: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fullPath = resolve(cwd, expandedPartial);
|
|
79
|
+
let dirToList: string;
|
|
80
|
+
let prefix: string;
|
|
81
|
+
let basePath: string;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { stat } = await import('fs/promises');
|
|
85
|
+
const stats = await stat(fullPath);
|
|
86
|
+
if (stats.isDirectory()) {
|
|
87
|
+
// Already a complete directory, append /
|
|
88
|
+
if (!partial.endsWith('/')) {
|
|
89
|
+
return { completed: partial + '/', isDirectory: true, alternatives: [] };
|
|
90
|
+
}
|
|
91
|
+
// List directory contents for alternatives
|
|
92
|
+
dirToList = fullPath;
|
|
93
|
+
prefix = '';
|
|
94
|
+
basePath = partial;
|
|
95
|
+
} else {
|
|
96
|
+
// Complete file path
|
|
97
|
+
return { completed: partial, isDirectory: false, alternatives: [] };
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Path doesn't exist, complete the last component
|
|
101
|
+
dirToList = dirname(fullPath);
|
|
102
|
+
prefix = basename(fullPath).toLowerCase();
|
|
103
|
+
basePath = tildePrefix ? tildePrefix + dirname(expandedPartial.slice(homedir().length + 1)) : dirname(partial);
|
|
104
|
+
if (basePath && !basePath.endsWith('/')) basePath += '/';
|
|
105
|
+
if (basePath === './') basePath = '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const entries = await readdir(dirToList, { withFileTypes: true });
|
|
110
|
+
const matches = entries
|
|
111
|
+
.filter(entry => entry.name.toLowerCase().startsWith(prefix))
|
|
112
|
+
.sort();
|
|
113
|
+
|
|
114
|
+
if (matches.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (matches.length === 1) {
|
|
119
|
+
const entry = matches[0];
|
|
120
|
+
const suffix = entry.isDirectory() ? '/' : '';
|
|
121
|
+
const completed = basePath + entry.name + suffix;
|
|
122
|
+
return { completed, isDirectory: entry.isDirectory(), alternatives: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Find common prefix among all matches
|
|
126
|
+
const names = matches.map(e => e.name);
|
|
127
|
+
let commonPrefix = names[0];
|
|
128
|
+
for (const name of names.slice(1)) {
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < commonPrefix.length && i < name.length && commonPrefix[i] === name[i]) {
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
commonPrefix = commonPrefix.slice(0, i);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const alternatives = matches.map(e => e.name + (e.isDirectory() ? '/' : ''));
|
|
137
|
+
const completed = basePath + commonPrefix;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
completed: completed.length > partial.length ? completed : partial,
|
|
141
|
+
isDirectory: false,
|
|
142
|
+
alternatives
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/utils/shell.ts
CHANGED
|
@@ -54,7 +54,16 @@ export async function runShellCommand(command: string, cwd: string): Promise<She
|
|
|
54
54
|
|
|
55
55
|
export async function resolveWorkingDir(current: string, target?: string): Promise<string> {
|
|
56
56
|
const base = current || process.cwd();
|
|
57
|
-
|
|
57
|
+
let path = target?.trim() || '';
|
|
58
|
+
|
|
59
|
+
// Expand tilde to home directory
|
|
60
|
+
if (path.startsWith('~/')) {
|
|
61
|
+
path = resolve(process.env.HOME || '', path.slice(2));
|
|
62
|
+
} else if (path === '~') {
|
|
63
|
+
path = process.env.HOME || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const next = path.length > 0 ? resolve(base, path) : base;
|
|
58
67
|
const info = await stat(next);
|
|
59
68
|
if (!info.isDirectory()) {
|
|
60
69
|
throw new Error('Not a directory');
|