Skip to content

Commit 5358e64

Browse files
feat: ✨ add --tty option to force interactive mode for commands and update related logic
1 parent 4ecacee commit 5358e64

File tree

3 files changed

+75
-39
lines changed

3 files changed

+75
-39
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ aish c "docker ps" --model gpt-4o # Use specific model
8888

8989
**Options:**
9090
- `-t, --timeout <seconds>` - Command timeout (no timeout by default)
91+
- `--tty` - Force interactive/TTY mode for the command
9192
- `-v, --verbose` - Show detailed explanations and context
9293
- `--provider <provider>` - Override default AI provider
9394
- `--model <model>` - Override provider's preferred model
@@ -98,6 +99,8 @@ aish c "docker ps" --model gpt-4o # Use specific model
9899
3. Asks for confirmation before execution
99100
4. Executes with real-time output
100101

102+
**Smart TTY Detection:** The AI automatically detects when commands need interactive terminal access (like `vim`, `nano`, `htop`) and enables TTY mode. Use `--tty` to force TTY mode for any command.
103+
101104
---
102105

103106
### ⚙️ `configure` - Setup AI Providers

src/commands/command.ts

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const CommandAnalysisSchema = z.object({
3131
.array(z.string())
3232
.optional()
3333
.describe("List of external packages required, if any"),
34+
needsInteractiveMode: z
35+
.boolean()
36+
.describe("Whether the user's intent is to run an interactive program that needs TTY"),
3437
});
3538

3639
/**
@@ -47,6 +50,9 @@ const FailureAnalysisSchema = z.object({
4750
.string()
4851
.nullable()
4952
.describe("Alternative command to try - should almost always be provided unless truly impossible"),
53+
needsInteractiveMode: z
54+
.boolean()
55+
.describe("Whether the alternative command needs TTY/interactive mode - only true if the failure was clearly due to missing TTY and the alternative requires it"),
5056
});
5157

5258
export type CommandAnalysis = z.infer<typeof CommandAnalysisSchema>;
@@ -97,11 +103,13 @@ class CommandExecutor {
97103
private model: LanguageModel;
98104
private timeoutMs?: number;
99105
private verbose: boolean;
106+
private forceTTY: boolean;
100107

101-
constructor(model: LanguageModel, timeoutMs?: number, verbose: boolean = false) {
108+
constructor(model: LanguageModel, timeoutMs?: number, verbose: boolean = false, forceTTY: boolean = false) {
102109
this.model = model;
103110
this.timeoutMs = timeoutMs;
104111
this.verbose = verbose;
112+
this.forceTTY = forceTTY;
105113
}
106114

107115
/**
@@ -115,7 +123,7 @@ class CommandExecutor {
115123
conversationHistory: [],
116124
};
117125

118-
while (context.state !== CommandState.SUCCESS &&
126+
while (context.state !== CommandState.SUCCESS &&
119127
context.state !== CommandState.ABORTED) {
120128
try {
121129
await this.processState(context);
@@ -138,15 +146,15 @@ class CommandExecutor {
138146
case CommandState.ANALYZING:
139147
await this.handleAnalyzing(context);
140148
break;
141-
149+
142150
case CommandState.CONFIRMING:
143151
await this.handleConfirming(context);
144152
break;
145-
153+
146154
case CommandState.EXECUTING:
147155
await this.handleExecuting(context);
148156
break;
149-
157+
150158
case CommandState.FAILED:
151159
await this.handleFailed(context);
152160
break;
@@ -188,12 +196,12 @@ class CommandExecutor {
188196
case UserAction.APPROVE:
189197
context.state = CommandState.EXECUTING;
190198
break;
191-
199+
192200
case UserAction.REJECT:
193201
console.log("aborted");
194202
context.state = CommandState.ABORTED;
195203
break;
196-
204+
197205
case UserAction.MODIFY:
198206
if (userAction.input) {
199207
console.log(chalk.gray(`Refining command based on: "${userAction.input}"`));
@@ -214,7 +222,10 @@ class CommandExecutor {
214222
return;
215223
}
216224

217-
const result = await this.executeCommand(context.currentAnalysis.command);
225+
const result = await this.executeCommand(
226+
context.currentAnalysis.command,
227+
context.currentAnalysis.needsInteractiveMode
228+
);
218229

219230
if (result.exitCode === 0) {
220231
context.state = CommandState.SUCCESS;
@@ -267,16 +278,17 @@ class CommandExecutor {
267278
...context.currentAnalysis,
268279
command: failureAnalysis.alternativeCommand,
269280
explanation: failureAnalysis.solution,
281+
needsInteractiveMode: failureAnalysis.needsInteractiveMode,
270282
};
271-
283+
272284
context.state = CommandState.CONFIRMING;
273285
} else {
274286
// Even without alternative command, allow user to modify
275287
console.log(chalk.gray("\nNo alternative command suggested. You can modify the request or abort."));
276-
288+
277289
// Prompt for user action
278290
const userAction = await this.promptUser("Try a different approach?");
279-
291+
280292
if (userAction.type === UserAction.MODIFY && userAction.input) {
281293
console.log(chalk.gray(`Refining based on: "${userAction.input}"`));
282294
context.query = userAction.input;
@@ -302,7 +314,7 @@ class CommandExecutor {
302314
conversationHistory: CoreMessage[]
303315
): Promise<CommandAnalysis> {
304316
const systemPrompt = "You are a shell command expert. You MUST respond with valid JSON only, no other text or formatting.";
305-
317+
306318
const userContent = conversationHistory.length > 0
307319
? `Based on our conversation, analyze this request and generate an appropriate shell command: "${query}"
308320
@@ -312,12 +324,14 @@ Return a JSON object with these exact fields:
312324
"explanation": "brief explanation of what the command does",
313325
"isDangerous": false,
314326
"requiresExternalPackages": false,
315-
"externalPackages": []
327+
"externalPackages": [],
328+
"needsInteractiveMode": false
316329
}
317330
318331
Set isDangerous to true ONLY for commands that could cause irreversible system damage or data loss (like rm -rf /, format, dd, etc.).
319332
Common development operations like removing lock files, node_modules, build artifacts, or temporary files are NOT dangerous.
320333
Set requiresExternalPackages to true and list packages if external tools are needed.
334+
Set needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.) or if the program they're trying to run is interactive in your knowledge. If unsure, assume false.
321335
322336
For file searches, prefer searching in user directories (~) rather than system-wide (/) to avoid permission issues and long execution times.
323337
@@ -330,11 +344,13 @@ Return a JSON object with these exact fields:
330344
"explanation": "brief explanation of what the command does",
331345
"isDangerous": false,
332346
"requiresExternalPackages": false,
333-
"externalPackages": []
347+
"externalPackages": [],
348+
"needsInteractiveMode": false
334349
}
335350
336351
Set isDangerous to true ONLY for commands that could cause irreversible system damage or data loss.
337352
Common development operations are NOT dangerous.
353+
Set needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.).
338354
339355
For file searches, prefer searching in user directories (~) rather than system-wide (/).
340356
@@ -387,7 +403,8 @@ Return a JSON object with these exact fields:
387403
{
388404
"explanation": "brief explanation of why the command failed (1-2 sentences max)",
389405
"solution": "how to fix the issue or what the user should do (1-2 sentences max)",
390-
"alternativeCommand": "alternative command to try (or null ONLY if absolutely no alternative exists)"
406+
"alternativeCommand": "alternative command to try (or null ONLY if absolutely no alternative exists)",
407+
"needsInteractiveMode": false
391408
}
392409
393410
IMPORTANT: You should ALMOST ALWAYS provide an alternativeCommand that attempts to fulfill the user's original request. Look at the error message and suggest a command that will work. For example:
@@ -401,6 +418,13 @@ Only return null for alternativeCommand in cases where:
401418
- The request is physically impossible (e.g., accessing hardware that doesn't exist)
402419
- The command requires user-specific information you don't have
403420
421+
IMPORTANT: Set needsInteractiveMode to true ONLY if ALL of these conditions are met:
422+
1. The failure was clearly caused by missing TTY/terminal (errors like "not a terminal", "no tty", input/output redirection issues)
423+
2. The alternative command you're suggesting is an interactive program (vim, nano, htop, etc.)
424+
3. Double-check: Does this alternative command actually need TTY to function properly?
425+
426+
If you're unsure about ANY of these conditions, set needsInteractiveMode to false. Be extremely conservative.
427+
404428
Be very concise and helpful. Keep explanations short. JSON only:`;
405429

406430
const messages: CoreMessage[] = [
@@ -441,6 +465,7 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
441465
explanation: plainText,
442466
solution: "See explanation above",
443467
alternativeCommand: null,
468+
needsInteractiveMode: false,
444469
};
445470
}
446471

@@ -451,16 +476,19 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
451476
* Execute shell command
452477
*/
453478
private executeCommand(
454-
command: string
479+
command: string,
480+
needsInteractiveMode: boolean = false
455481
): Promise<{
456482
exitCode: number;
457483
stdout: string;
458484
stderr: string;
459485
}> {
460486
return new Promise((resolve) => {
461487
const wrappedCommand = `set -o pipefail; ${command}`;
488+
const useInteractive = this.forceTTY || needsInteractiveMode;
489+
462490
const child = spawn("sh", ["-c", wrappedCommand], {
463-
stdio: ["inherit", "pipe", "pipe"],
491+
stdio: useInteractive ? "inherit" : ["inherit", "pipe", "pipe"],
464492
});
465493

466494
let stdout = "";
@@ -483,26 +511,28 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
483511
}, this.timeoutMs);
484512
}
485513

486-
child.stdout?.on("data", (data) => {
487-
const output = data.toString();
488-
process.stdout.write(output);
489-
stdout += output;
490-
});
491-
492-
child.stderr?.on("data", (data) => {
493-
const output = data.toString();
494-
process.stderr.write(output);
495-
stderr += output;
496-
});
514+
if (!useInteractive) {
515+
child.stdout?.on("data", (data) => {
516+
const output = data.toString();
517+
process.stdout.write(output);
518+
stdout += output;
519+
});
520+
521+
child.stderr?.on("data", (data) => {
522+
const output = data.toString();
523+
process.stderr.write(output);
524+
stderr += output;
525+
});
526+
}
497527

498528
child.on("close", (code) => {
499529
if (!resolved) {
500530
resolved = true;
501531
if (timeout) clearTimeout(timeout);
502532
resolve({
503533
exitCode: code || 0,
504-
stdout,
505-
stderr,
534+
stdout: useInteractive ? "Interactive command completed" : stdout,
535+
stderr: useInteractive ? "" : stderr,
506536
});
507537
}
508538
});
@@ -545,20 +575,20 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
545575
});
546576

547577
const trimmed = response.trim().toLowerCase();
548-
578+
549579
if (trimmed === 'y' || trimmed === 'yes') {
550580
return { type: UserAction.APPROVE };
551581
}
552-
582+
553583
if (trimmed === 'n' || trimmed === 'no' || trimmed === '') {
554584
return { type: UserAction.REJECT };
555585
}
556-
586+
557587
// Multi-character input is treated as modification
558588
if (trimmed.length > 1) {
559589
return { type: UserAction.MODIFY, input: response.trim() };
560590
}
561-
591+
562592
return { type: UserAction.REJECT };
563593
}
564594

@@ -616,7 +646,7 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
616646

617647
if (this.verbose) {
618648
console.log(chalk.gray(`\n${analysis.explanation}`));
619-
649+
620650
if (analysis.requiresExternalPackages && analysis.externalPackages?.length) {
621651
console.log(chalk.yellow(`\nRequires external packages: ${analysis.externalPackages.join(', ')}`));
622652
}
@@ -650,7 +680,7 @@ Briefly explain why it failed and how to fix it (1-2 sentences max).`,
650680
* Check if error is permission-related
651681
*/
652682
private isPermissionError(stderr: string): boolean {
653-
return stderr.includes("Permission denied") ||
683+
return stderr.includes("Permission denied") ||
654684
stderr.includes("Operation not permitted");
655685
}
656686
}
@@ -663,11 +693,12 @@ export async function handleCommandGeneration(
663693
query: string,
664694
timeoutSeconds?: number,
665695
verbose: boolean = false,
696+
forceTTY: boolean = false,
666697
): Promise<void> {
667698
const timeoutMs = timeoutSeconds ? timeoutSeconds * 1000 : undefined;
668-
const executor = new CommandExecutor(model, timeoutMs, verbose);
699+
const executor = new CommandExecutor(model, timeoutMs, verbose, forceTTY);
669700
await executor.execute(query);
670701
}
671702

672703
// Export for testing
673-
export { CommandExecutor, CommandState, UserAction };
704+
export { CommandExecutor, CommandState, UserAction };

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ program
255255
"-t, --timeout <seconds>",
256256
"timeout in seconds (no timeout by default)",
257257
)
258+
.option("--tty", "force interactive/TTY mode for the command")
258259
.option("-v, --verbose", "show detailed explanations and context")
259260
.option("--provider <provider>", "AI provider to use (overrides default)")
260261
.option(
@@ -268,6 +269,7 @@ program
268269
? parseInt(options.timeout)
269270
: undefined;
270271
const verbose = options.verbose || false;
272+
const forceTTY = options.tty || false;
271273

272274
try {
273275
const config = loadConfig();
@@ -276,7 +278,7 @@ program
276278
options.provider,
277279
options.model,
278280
);
279-
await handleCommandGeneration(model, query, timeoutSeconds, verbose);
281+
await handleCommandGeneration(model, query, timeoutSeconds, verbose, forceTTY);
280282
} catch (error) {
281283
console.log(
282284
chalk.red(

0 commit comments

Comments
 (0)