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.
Files changed (44) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +27 -8
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/agent.d.ts +4 -0
  5. package/dist/agent/agent.d.ts.map +1 -1
  6. package/dist/agent/agent.js +21 -3
  7. package/dist/agent/agent.js.map +1 -1
  8. package/dist/agent/tools/file.d.ts.map +1 -1
  9. package/dist/agent/tools/file.js +10 -6
  10. package/dist/agent/tools/file.js.map +1 -1
  11. package/dist/agent/tools/index.d.ts +2 -2
  12. package/dist/agent/tools/index.d.ts.map +1 -1
  13. package/dist/agent/tools/index.js +2 -2
  14. package/dist/agent/tools/index.js.map +1 -1
  15. package/dist/agent/tools/search.d.ts.map +1 -1
  16. package/dist/agent/tools/search.js +5 -4
  17. package/dist/agent/tools/search.js.map +1 -1
  18. package/dist/agent/tools/shell.d.ts.map +1 -1
  19. package/dist/agent/tools/shell.js +7 -3
  20. package/dist/agent/tools/shell.js.map +1 -1
  21. package/dist/agent/tools/types.d.ts +4 -1
  22. package/dist/agent/tools/types.d.ts.map +1 -1
  23. package/dist/components/InputArea.d.ts +1 -0
  24. package/dist/components/InputArea.d.ts.map +1 -1
  25. package/dist/components/InputArea.js +59 -1
  26. package/dist/components/InputArea.js.map +1 -1
  27. package/dist/utils/path_complete.d.ts +13 -0
  28. package/dist/utils/path_complete.d.ts.map +1 -0
  29. package/dist/utils/path_complete.js +137 -0
  30. package/dist/utils/path_complete.js.map +1 -0
  31. package/dist/utils/shell.d.ts.map +1 -1
  32. package/dist/utils/shell.js +9 -1
  33. package/dist/utils/shell.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/App.tsx +26 -7
  36. package/src/agent/agent.ts +26 -6
  37. package/src/agent/tools/file.ts +24 -19
  38. package/src/agent/tools/index.ts +5 -4
  39. package/src/agent/tools/search.ts +6 -5
  40. package/src/agent/tools/shell.ts +13 -9
  41. package/src/agent/tools/types.ts +5 -1
  42. package/src/components/InputArea.tsx +70 -3
  43. package/src/utils/path_complete.ts +147 -0
  44. package/src/utils/shell.ts +10 -1
@@ -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' } => m.role === 'user' || m.role === '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';
@@ -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 = resolve(String(args.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 = resolve(String(args.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 = resolve(String(args.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 });
@@ -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, process.cwd());
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, process.cwd());
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
  }
@@ -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 cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
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
- throw new Error(`Command failed: ${e.message}\nstderr: ${e.stderr || ''}`);
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
  };
@@ -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>) => Promise<string>;
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
+ }
@@ -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
- const next = target && target.trim().length > 0 ? resolve(base, target) : base;
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');