prompt-language-shell 0.3.0 → 0.3.2

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/index.js CHANGED
@@ -4,8 +4,6 @@ import { existsSync, readFileSync } from 'fs';
4
4
  import { dirname, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { render } from 'ink';
7
- import { hasValidConfig, loadConfig, saveAnthropicConfig, } from './services/config.js';
8
- import { createAnthropicService } from './services/anthropic.js';
9
7
  import { Main } from './ui/Main.js';
10
8
  const __filename = fileURLToPath(import.meta.url);
11
9
  const __dirname = dirname(__filename);
@@ -26,26 +24,5 @@ const app = {
26
24
  // Get command from command-line arguments
27
25
  const args = process.argv.slice(2);
28
26
  const command = args.join(' ').trim() || null;
29
- async function runApp() {
30
- // Happy path: valid config exists
31
- if (hasValidConfig()) {
32
- const config = loadConfig();
33
- const service = createAnthropicService(config.anthropic);
34
- render(_jsx(Main, { app: app, command: command, service: service, isReady: true }));
35
- return;
36
- }
37
- // Setup: config doesn't exist or is invalid
38
- const { waitUntilExit, unmount } = render(_jsx(Main, { app: app, command: command, isReady: false, onConfigured: (config) => {
39
- saveAnthropicConfig(config);
40
- if (command) {
41
- return createAnthropicService(config);
42
- }
43
- else {
44
- // No command - exit after showing completion message
45
- setTimeout(() => unmount(), 100);
46
- return undefined;
47
- }
48
- } }));
49
- await waitUntilExit();
50
- }
51
- runApp();
27
+ // Render application
28
+ render(_jsx(Main, { app: app, command: command }));
@@ -0,0 +1,90 @@
1
+ import { ComponentName, } from '../types/components.js';
2
+ import { StepType } from '../ui/Config.js';
3
+ import { AnthropicModel, isValidAnthropicApiKey, isValidAnthropicModel, } from './config.js';
4
+ export function markAsDone(component) {
5
+ return { ...component, state: { ...component.state, done: true } };
6
+ }
7
+ export function createWelcomeDefinition(app) {
8
+ return {
9
+ name: ComponentName.Welcome,
10
+ props: { app },
11
+ };
12
+ }
13
+ export function createConfigSteps() {
14
+ return [
15
+ {
16
+ description: 'Anthropic API key',
17
+ key: 'key',
18
+ type: StepType.Text,
19
+ value: null,
20
+ validate: isValidAnthropicApiKey,
21
+ },
22
+ {
23
+ description: 'Model',
24
+ key: 'model',
25
+ type: StepType.Selection,
26
+ options: [
27
+ { label: 'Haiku 4.5', value: AnthropicModel.Haiku },
28
+ { label: 'Sonnet 4.5', value: AnthropicModel.Sonnet },
29
+ { label: 'Opus 4.1', value: AnthropicModel.Opus },
30
+ ],
31
+ defaultIndex: 0,
32
+ validate: isValidAnthropicModel,
33
+ },
34
+ ];
35
+ }
36
+ export function createConfigDefinition(onFinished, onAborted) {
37
+ return {
38
+ name: ComponentName.Config,
39
+ state: { done: false },
40
+ props: {
41
+ steps: createConfigSteps(),
42
+ onFinished,
43
+ onAborted,
44
+ },
45
+ };
46
+ }
47
+ export function createCommandDefinition(command, service, onError, onComplete) {
48
+ return {
49
+ name: ComponentName.Command,
50
+ state: {
51
+ done: false,
52
+ isLoading: true,
53
+ },
54
+ props: {
55
+ command,
56
+ service,
57
+ onError,
58
+ onComplete,
59
+ },
60
+ };
61
+ }
62
+ export function createPlanDefinition(message, tasks) {
63
+ return {
64
+ name: ComponentName.Plan,
65
+ props: {
66
+ message,
67
+ tasks,
68
+ },
69
+ };
70
+ }
71
+ export function createFeedback(type, ...messages) {
72
+ return {
73
+ name: ComponentName.Feedback,
74
+ props: {
75
+ type,
76
+ message: messages.join('\n\n'),
77
+ },
78
+ };
79
+ }
80
+ export function createMessage(text) {
81
+ return {
82
+ name: ComponentName.Message,
83
+ props: {
84
+ text,
85
+ },
86
+ };
87
+ }
88
+ export function isStateless(component) {
89
+ return !('state' in component);
90
+ }
@@ -2,6 +2,13 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
  import YAML from 'yaml';
5
+ export var AnthropicModel;
6
+ (function (AnthropicModel) {
7
+ AnthropicModel["Sonnet"] = "claude-sonnet-4-5";
8
+ AnthropicModel["Haiku"] = "claude-haiku-4-5";
9
+ AnthropicModel["Opus"] = "claude-opus-4-1";
10
+ })(AnthropicModel || (AnthropicModel = {}));
11
+ export const SUPPORTED_MODELS = Object.values(AnthropicModel);
5
12
  export class ConfigError extends Error {
6
13
  origin;
7
14
  constructor(message, origin) {
@@ -10,7 +17,9 @@ export class ConfigError extends Error {
10
17
  this.origin = origin;
11
18
  }
12
19
  }
13
- const CONFIG_FILE = join(homedir(), '.plsrc');
20
+ function getConfigFile() {
21
+ return join(homedir(), '.plsrc');
22
+ }
14
23
  function parseYamlConfig(content) {
15
24
  try {
16
25
  return YAML.parse(content);
@@ -37,30 +46,41 @@ function validateConfig(parsed) {
37
46
  key,
38
47
  },
39
48
  };
40
- // Optional model
41
- if (model && typeof model === 'string') {
49
+ // Optional model - only set if valid
50
+ if (model && typeof model === 'string' && isValidAnthropicModel(model)) {
42
51
  validatedConfig.anthropic.model = model;
43
52
  }
44
53
  return validatedConfig;
45
54
  }
46
55
  export function loadConfig() {
47
- if (!existsSync(CONFIG_FILE)) {
56
+ const configFile = getConfigFile();
57
+ if (!existsSync(configFile)) {
48
58
  throw new ConfigError('Configuration not found');
49
59
  }
50
- const content = readFileSync(CONFIG_FILE, 'utf-8');
60
+ const content = readFileSync(configFile, 'utf-8');
51
61
  const parsed = parseYamlConfig(content);
52
62
  return validateConfig(parsed);
53
63
  }
54
64
  export function getConfigPath() {
55
- return CONFIG_FILE;
65
+ return getConfigFile();
56
66
  }
57
67
  export function configExists() {
58
- return existsSync(CONFIG_FILE);
68
+ return existsSync(getConfigFile());
69
+ }
70
+ export function isValidAnthropicApiKey(key) {
71
+ // Anthropic API keys format: sk-ant-api03-XXXXX (108 chars total)
72
+ // - Prefix: sk-ant-api03- (13 chars)
73
+ // - Key body: 95 characters (uppercase, lowercase, digits, hyphens, underscores)
74
+ const apiKeyPattern = /^sk-ant-api03-[A-Za-z0-9_-]{95}$/;
75
+ return apiKeyPattern.test(key);
76
+ }
77
+ export function isValidAnthropicModel(model) {
78
+ return SUPPORTED_MODELS.includes(model);
59
79
  }
60
- export function hasValidConfig() {
80
+ export function hasValidAnthropicKey() {
61
81
  try {
62
82
  const config = loadConfig();
63
- return !!config.anthropic.key;
83
+ return (!!config.anthropic.key && isValidAnthropicApiKey(config.anthropic.key));
64
84
  }
65
85
  catch {
66
86
  return false;
@@ -86,12 +106,43 @@ export function mergeConfig(existingContent, sectionName, newValues) {
86
106
  return YAML.stringify(sortedConfig);
87
107
  }
88
108
  export function saveConfig(section, config) {
89
- const existingContent = existsSync(CONFIG_FILE)
90
- ? readFileSync(CONFIG_FILE, 'utf-8')
109
+ const configFile = getConfigFile();
110
+ const existingContent = existsSync(configFile)
111
+ ? readFileSync(configFile, 'utf-8')
91
112
  : '';
92
113
  const newContent = mergeConfig(existingContent, section, config);
93
- writeFileSync(CONFIG_FILE, newContent, 'utf-8');
114
+ writeFileSync(configFile, newContent, 'utf-8');
94
115
  }
95
116
  export function saveAnthropicConfig(config) {
96
117
  saveConfig('anthropic', config);
97
118
  }
119
+ /**
120
+ * Returns a message requesting initial setup.
121
+ * Provides natural language variations that sound like a professional concierge
122
+ * preparing to serve, avoiding technical jargon.
123
+ *
124
+ * @param forFutureUse - If true, indicates setup is for future requests rather than
125
+ * an immediate task
126
+ */
127
+ export function getConfigurationRequiredMessage(forFutureUse = false) {
128
+ if (forFutureUse) {
129
+ const messages = [
130
+ "Before I can assist with your requests, let's get a few things ready.",
131
+ 'Let me set up a few things so I can help you in the future.',
132
+ "I'll need to prepare a few things before I can assist you.",
133
+ "Let's get everything ready so I can help with your tasks.",
134
+ "I need to set up a few things first, then I'll be ready to assist.",
135
+ 'Let me prepare everything so I can help you going forward.',
136
+ ];
137
+ return messages[Math.floor(Math.random() * messages.length)];
138
+ }
139
+ const messages = [
140
+ 'Before I can help, let me get a few things ready.',
141
+ 'I need to set up a few things first.',
142
+ 'Let me prepare everything before we begin.',
143
+ 'Just a moment while I get ready to assist you.',
144
+ "I'll need to get set up before I can help with that.",
145
+ 'Let me get everything ready for you.',
146
+ ];
147
+ return messages[Math.floor(Math.random() * messages.length)];
148
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Exit application after brief delay to allow UI to render
3
+ */
4
+ export function exitApp(code) {
5
+ setTimeout(() => process.exit(code), 100);
6
+ }
@@ -1,3 +1,12 @@
1
+ export var ComponentName;
2
+ (function (ComponentName) {
3
+ ComponentName["Welcome"] = "welcome";
4
+ ComponentName["Config"] = "config";
5
+ ComponentName["Feedback"] = "feedback";
6
+ ComponentName["Message"] = "message";
7
+ ComponentName["Plan"] = "plan";
8
+ ComponentName["Command"] = "command";
9
+ })(ComponentName || (ComponentName = {}));
1
10
  export var TaskType;
2
11
  (function (TaskType) {
3
12
  TaskType["Config"] = "config";
@@ -11,6 +20,7 @@ export var TaskType;
11
20
  })(TaskType || (TaskType = {}));
12
21
  export var FeedbackType;
13
22
  (function (FeedbackType) {
23
+ FeedbackType["Info"] = "info";
14
24
  FeedbackType["Succeeded"] = "succeeded";
15
25
  FeedbackType["Aborted"] = "aborted";
16
26
  FeedbackType["Failed"] = "failed";
package/dist/ui/Column.js CHANGED
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { Component } from './Component.js';
4
4
  export const Column = ({ items }) => {
5
- return (_jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", gap: 1, children: items.map((item, index) => (_jsx(Box, { children: _jsx(Component, { def: item }) }, index))) }));
5
+ return (_jsx(Box, { marginTop: 1, marginBottom: 1, marginLeft: 1, flexDirection: "column", gap: 1, children: items.map((item, index) => (_jsx(Box, { children: _jsx(Component, { def: item }) }, index))) }));
6
6
  };
@@ -1,80 +1,12 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Box, Text } from 'ink';
4
- import { TaskType } from '../types/components.js';
5
- import { Label } from './Label.js';
6
- import { List } from './List.js';
7
4
  import { Spinner } from './Spinner.js';
8
5
  const MIN_PROCESSING_TIME = 1000; // purely for visual effect
9
- // Color palette
10
- const ColorPalette = {
11
- [TaskType.Config]: {
12
- description: '#ffffff', // white
13
- type: '#5c9ccc', // cyan
14
- },
15
- [TaskType.Plan]: {
16
- description: '#ffffff', // white
17
- type: '#5ccccc', // magenta
18
- },
19
- [TaskType.Execute]: {
20
- description: '#ffffff', // white
21
- type: '#4a9a7a', // green
22
- },
23
- [TaskType.Answer]: {
24
- description: '#ffffff', // white
25
- type: '#9c5ccc', // purple
26
- },
27
- [TaskType.Report]: {
28
- description: '#ffffff', // white
29
- type: '#cc9c5c', // orange
30
- },
31
- [TaskType.Define]: {
32
- description: '#ffffff', // white
33
- type: '#cc9c5c', // amber
34
- },
35
- [TaskType.Ignore]: {
36
- description: '#cccc5c', // yellow
37
- type: '#cc7a5c', // orange
38
- },
39
- [TaskType.Select]: {
40
- description: '#888888', // grey
41
- type: '#5c8cbc', // steel blue
42
- },
43
- };
44
- function taskToListItem(task) {
45
- const colors = ColorPalette[task.type];
46
- const item = {
47
- description: {
48
- text: task.action,
49
- color: colors.description,
50
- },
51
- type: {
52
- text: task.type,
53
- color: colors.type,
54
- },
55
- };
56
- // Add children for Define tasks with options
57
- if (task.type === TaskType.Define && Array.isArray(task.params?.options)) {
58
- const selectColors = ColorPalette[TaskType.Select];
59
- item.children = task.params.options.map((option) => ({
60
- description: {
61
- text: String(option),
62
- color: selectColors.description,
63
- },
64
- type: {
65
- text: TaskType.Select,
66
- color: selectColors.type,
67
- },
68
- }));
69
- }
70
- return item;
71
- }
72
6
  export function Command({ command, state, service, children, onError, onComplete, }) {
73
7
  const done = state?.done ?? false;
74
8
  const [error, setError] = useState(null);
75
9
  const [isLoading, setIsLoading] = useState(state?.isLoading ?? !done);
76
- const [message, setMessage] = useState('');
77
- const [tasks, setTasks] = useState([]);
78
10
  useEffect(() => {
79
11
  // Skip processing if done (showing historical/final state)
80
12
  if (done) {
@@ -95,10 +27,8 @@ export function Command({ command, state, service, children, onError, onComplete
95
27
  const remainingTime = Math.max(0, MIN_PROCESSING_TIME - elapsed);
96
28
  await new Promise((resolve) => setTimeout(resolve, remainingTime));
97
29
  if (mounted) {
98
- setMessage(result.message);
99
- setTasks(result.tasks);
100
30
  setIsLoading(false);
101
- onComplete?.();
31
+ onComplete?.(result.message, result.tasks);
102
32
  }
103
33
  }
104
34
  catch (err) {
@@ -122,5 +52,5 @@ export function Command({ command, state, service, children, onError, onComplete
122
52
  mounted = false;
123
53
  };
124
54
  }, [command, done, service]);
125
- return (_jsxs(Box, { alignSelf: "flex-start", flexDirection: "column", marginLeft: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ["> pls ", command] }), isLoading && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Spinner, {})] }))] }), error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), !isLoading && tasks.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [message && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: " " }), _jsx(Label, { description: message, descriptionColor: ColorPalette[TaskType.Plan].description, type: TaskType.Plan, typeColor: ColorPalette[TaskType.Plan].type })] })), _jsx(List, { items: tasks.map(taskToListItem) })] })), children] }));
55
+ return (_jsxs(Box, { alignSelf: "flex-start", flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ["> pls ", command] }), isLoading && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Spinner, {})] }))] }), error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), children] }));
126
56
  }
@@ -1,23 +1,30 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ComponentName } from '../types/components.js';
2
3
  import { Command } from './Command.js';
3
4
  import { Config } from './Config.js';
4
5
  import { Feedback } from './Feedback.js';
6
+ import { Message } from './Message.js';
7
+ import { Plan } from './Plan.js';
5
8
  import { Welcome } from './Welcome.js';
6
9
  export function Component({ def }) {
7
10
  switch (def.name) {
8
- case 'welcome':
11
+ case ComponentName.Welcome:
9
12
  return _jsx(Welcome, { ...def.props });
10
- case 'config': {
13
+ case ComponentName.Config: {
11
14
  const props = def.props;
12
15
  const state = def.state;
13
16
  return _jsx(Config, { ...props, state: state });
14
17
  }
15
- case 'feedback':
16
- return _jsx(Feedback, { ...def.props });
17
- case 'command': {
18
+ case ComponentName.Command: {
18
19
  const props = def.props;
19
20
  const state = def.state;
20
21
  return _jsx(Command, { ...props, state: state });
21
22
  }
23
+ case ComponentName.Plan:
24
+ return _jsx(Plan, { ...def.props });
25
+ case ComponentName.Feedback:
26
+ return _jsx(Feedback, { ...def.props });
27
+ case ComponentName.Message:
28
+ return _jsx(Message, { ...def.props });
22
29
  }
23
30
  }
package/dist/ui/Config.js CHANGED
@@ -1,20 +1,87 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { Box, Text, useInput } from 'ink';
3
+ import { Box, Text, useInput, useFocus } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
+ export var StepType;
6
+ (function (StepType) {
7
+ StepType["Text"] = "text";
8
+ StepType["Selection"] = "selection";
9
+ })(StepType || (StepType = {}));
10
+ function TextStep({ value, placeholder, validate, onChange, onSubmit, }) {
11
+ const [inputValue, setInputValue] = React.useState(value);
12
+ const [validationFailed, setValidationFailed] = React.useState(false);
13
+ const { isFocused } = useFocus({ autoFocus: true });
14
+ const handleChange = (newValue) => {
15
+ setInputValue(newValue);
16
+ onChange(newValue);
17
+ if (validationFailed) {
18
+ setValidationFailed(false);
19
+ }
20
+ };
21
+ const handleSubmit = (value) => {
22
+ if (!validate(value)) {
23
+ setValidationFailed(true);
24
+ return;
25
+ }
26
+ onSubmit(value);
27
+ };
28
+ // Handle input manually when validation fails
29
+ useInput((input, key) => {
30
+ if (!validationFailed)
31
+ return;
32
+ if (key.return) {
33
+ handleSubmit(inputValue);
34
+ }
35
+ else if (key.backspace || key.delete) {
36
+ const newValue = inputValue.slice(0, -1);
37
+ handleChange(newValue);
38
+ }
39
+ else if (!key.ctrl && !key.meta && input) {
40
+ const newValue = inputValue + input;
41
+ handleChange(newValue);
42
+ }
43
+ }, { isActive: validationFailed });
44
+ // When validation fails, show colored text
45
+ if (validationFailed) {
46
+ return (_jsxs(Text, { color: "#cc5c5c", children: [inputValue || placeholder, isFocused && _jsx(Text, { inverse: true, children: " " })] }));
47
+ }
48
+ return (_jsx(TextInput, { value: inputValue, onChange: handleChange, onSubmit: handleSubmit, placeholder: placeholder }));
49
+ }
50
+ function SelectionStep({ options, selectedIndex, isCurrentStep, }) {
51
+ return (_jsx(Box, { children: options.map((option, optIndex) => {
52
+ const isSelected = optIndex === selectedIndex;
53
+ return (_jsx(Box, { marginRight: 2, children: _jsx(Text, { dimColor: !isSelected || !isCurrentStep, bold: isSelected, children: option.label }) }, option.value));
54
+ }) }));
55
+ }
5
56
  export function Config({ steps, state, onFinished, onAborted }) {
6
57
  const done = state?.done ?? false;
7
58
  const [step, setStep] = React.useState(done ? steps.length : 0);
8
59
  const [values, setValues] = React.useState(() => {
9
60
  const initial = {};
10
- steps.forEach((step) => {
11
- if (step.value !== null) {
12
- initial[step.key] = step.value;
61
+ steps.forEach((stepConfig) => {
62
+ switch (stepConfig.type) {
63
+ case StepType.Text:
64
+ if (stepConfig.value !== null) {
65
+ initial[stepConfig.key] = stepConfig.value;
66
+ }
67
+ break;
68
+ case StepType.Selection:
69
+ initial[stepConfig.key] =
70
+ stepConfig.options[stepConfig.defaultIndex].value;
71
+ break;
72
+ default: {
73
+ const exhaustiveCheck = stepConfig;
74
+ throw new Error(`Unsupported step type: ${exhaustiveCheck}`);
75
+ }
13
76
  }
14
77
  });
15
78
  return initial;
16
79
  });
17
80
  const [inputValue, setInputValue] = React.useState('');
81
+ const [selectedIndex, setSelectedIndex] = React.useState(() => {
82
+ const firstStep = steps[0];
83
+ return firstStep?.type === StepType.Selection ? firstStep.defaultIndex : 0;
84
+ });
18
85
  const normalizeValue = (value) => {
19
86
  if (value === null || value === undefined) {
20
87
  return '';
@@ -23,16 +90,83 @@ export function Config({ steps, state, onFinished, onAborted }) {
23
90
  };
24
91
  useInput((input, key) => {
25
92
  if (key.escape && !done && step < steps.length) {
93
+ // Save current value before aborting
94
+ const currentStepConfig = steps[step];
95
+ if (currentStepConfig) {
96
+ let currentValue = '';
97
+ switch (currentStepConfig.type) {
98
+ case StepType.Text:
99
+ currentValue = inputValue || values[currentStepConfig.key] || '';
100
+ break;
101
+ case StepType.Selection:
102
+ currentValue =
103
+ currentStepConfig.options[selectedIndex]?.value ||
104
+ values[currentStepConfig.key] ||
105
+ '';
106
+ break;
107
+ default: {
108
+ const exhaustiveCheck = currentStepConfig;
109
+ throw new Error(`Unsupported step type: ${exhaustiveCheck}`);
110
+ }
111
+ }
112
+ if (currentValue) {
113
+ setValues({ ...values, [currentStepConfig.key]: currentValue });
114
+ }
115
+ }
26
116
  if (onAborted) {
27
117
  onAborted();
28
118
  }
119
+ return;
120
+ }
121
+ const currentStep = steps[step];
122
+ if (!done && step < steps.length && currentStep) {
123
+ switch (currentStep.type) {
124
+ case StepType.Selection:
125
+ if (key.tab) {
126
+ setSelectedIndex((prev) => (prev + 1) % currentStep.options.length);
127
+ }
128
+ else if (key.return) {
129
+ handleSubmit(currentStep.options[selectedIndex].value);
130
+ }
131
+ break;
132
+ case StepType.Text:
133
+ // Text input handled by TextInput component
134
+ break;
135
+ default: {
136
+ const exhaustiveCheck = currentStep;
137
+ throw new Error(`Unsupported step type: ${exhaustiveCheck}`);
138
+ }
139
+ }
29
140
  }
30
141
  });
31
142
  const handleSubmit = (value) => {
32
143
  const currentStepConfig = steps[step];
33
- const finalValue = normalizeValue(value) || normalizeValue(currentStepConfig.value);
34
- // Don't allow empty value if step has no default (mandatory field)
35
- if (!finalValue && !currentStepConfig.value) {
144
+ let finalValue = '';
145
+ switch (currentStepConfig.type) {
146
+ case StepType.Selection:
147
+ // For selection, value is already validated by options
148
+ finalValue = value;
149
+ break;
150
+ case StepType.Text: {
151
+ // For text input
152
+ const normalizedInput = normalizeValue(value);
153
+ // Try user input first, then fall back to default
154
+ if (normalizedInput && currentStepConfig.validate(normalizedInput)) {
155
+ finalValue = normalizedInput;
156
+ }
157
+ else if (currentStepConfig.value &&
158
+ currentStepConfig.validate(currentStepConfig.value)) {
159
+ finalValue = currentStepConfig.value;
160
+ }
161
+ break;
162
+ }
163
+ default: {
164
+ const exhaustiveCheck = currentStepConfig;
165
+ throw new Error(`Unsupported step type: ${exhaustiveCheck}`);
166
+ }
167
+ }
168
+ // Don't allow empty or invalid value
169
+ if (!finalValue) {
36
170
  return;
37
171
  }
38
172
  const newValues = { ...values, [currentStepConfig.key]: finalValue };
@@ -47,9 +181,34 @@ export function Config({ steps, state, onFinished, onAborted }) {
47
181
  }
48
182
  else {
49
183
  setStep(step + 1);
184
+ // Reset selection index for next step
185
+ const nextStep = steps[step + 1];
186
+ if (nextStep?.type === StepType.Selection) {
187
+ setSelectedIndex(nextStep.defaultIndex);
188
+ }
189
+ }
190
+ };
191
+ const renderStepInput = (stepConfig, isCurrentStep) => {
192
+ switch (stepConfig.type) {
193
+ case StepType.Text:
194
+ if (isCurrentStep) {
195
+ return (_jsx(TextStep, { value: inputValue, placeholder: stepConfig.value || undefined, validate: stepConfig.validate, onChange: setInputValue, onSubmit: handleSubmit }));
196
+ }
197
+ return _jsx(Text, { dimColor: true, children: values[stepConfig.key] || '' });
198
+ case StepType.Selection: {
199
+ if (!isCurrentStep) {
200
+ const selectedOption = stepConfig.options.find((opt) => opt.value === values[stepConfig.key]);
201
+ return _jsx(Text, { dimColor: true, children: selectedOption?.label || '' });
202
+ }
203
+ return (_jsx(SelectionStep, { options: stepConfig.options, selectedIndex: selectedIndex, isCurrentStep: isCurrentStep }));
204
+ }
205
+ default: {
206
+ const exhaustiveCheck = stepConfig;
207
+ throw new Error(`Unsupported step type: ${exhaustiveCheck}`);
208
+ }
50
209
  }
51
210
  };
52
- return (_jsx(Box, { flexDirection: "column", marginLeft: 1, children: steps.map((stepConfig, index) => {
211
+ return (_jsx(Box, { flexDirection: "column", children: steps.map((stepConfig, index) => {
53
212
  const isCurrentStep = index === step && !done;
54
213
  const isCompleted = index < step;
55
214
  const wasAborted = index === step && done;
@@ -57,6 +216,6 @@ export function Config({ steps, state, onFinished, onAborted }) {
57
216
  if (!shouldShow) {
58
217
  return null;
59
218
  }
60
- return (_jsxs(Box, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { children: [stepConfig.description, ":"] }) }), _jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "#5c8cbc", dimColor: !isCurrentStep, children: ">" }), _jsx(Text, { children: " " }), isCurrentStep ? (_jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleSubmit, placeholder: stepConfig.value || undefined })) : (_jsx(Text, { dimColor: true, children: values[stepConfig.key] || '' }))] })] }, stepConfig.key));
219
+ return (_jsxs(Box, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { children: [stepConfig.description, ":"] }) }), _jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "#5c8cbc", dimColor: !isCurrentStep, children: ">" }), _jsx(Text, { children: " " }), renderStepInput(stepConfig, isCurrentStep)] })] }, stepConfig.key));
61
220
  }) }));
62
221
  }
@@ -3,20 +3,31 @@ import { Box, Text } from 'ink';
3
3
  import { FeedbackType } from '../types/components.js';
4
4
  function getSymbol(type) {
5
5
  return {
6
+ [FeedbackType.Info]: 'ℹ',
6
7
  [FeedbackType.Succeeded]: '✓',
7
8
  [FeedbackType.Aborted]: '⊘',
8
9
  [FeedbackType.Failed]: '✗',
9
10
  }[type];
10
11
  }
11
- function getColor(type) {
12
+ function getSymbolColor(type) {
12
13
  return {
14
+ [FeedbackType.Info]: '#5c9ccc', // cyan
13
15
  [FeedbackType.Succeeded]: '#00aa00', // green
14
16
  [FeedbackType.Aborted]: '#cc9c5c', // orange
15
- [FeedbackType.Failed]: '#aa0000', // red
17
+ [FeedbackType.Failed]: '#cc5c5c', // red
18
+ }[type];
19
+ }
20
+ function getMessageColor(type) {
21
+ return {
22
+ [FeedbackType.Info]: '#aaaaaa', // light grey
23
+ [FeedbackType.Succeeded]: '#5ccc5c', // green
24
+ [FeedbackType.Aborted]: '#cc9c5c', // orange
25
+ [FeedbackType.Failed]: '#cc5c5c', // red
16
26
  }[type];
17
27
  }
18
28
  export function Feedback({ type, message }) {
19
- const color = getColor(type);
29
+ const symbolColor = getSymbolColor(type);
30
+ const messageColor = getMessageColor(type);
20
31
  const symbol = getSymbol(type);
21
- return (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: color, children: [symbol, " ", message] }) }));
32
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: symbolColor, children: [symbol, " "] }), _jsx(Text, { color: messageColor, children: message })] }));
22
33
  }
package/dist/ui/Main.js CHANGED
@@ -1,133 +1,134 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
+ import { ComponentName, } from '../types/components.js';
4
+ import { createAnthropicService, } from '../services/anthropic.js';
3
5
  import { FeedbackType } from '../types/components.js';
6
+ import { getConfigurationRequiredMessage, hasValidAnthropicKey, loadConfig, saveAnthropicConfig, } from '../services/config.js';
7
+ import { createCommandDefinition, createConfigDefinition, createFeedback, createMessage, createPlanDefinition, createWelcomeDefinition, isStateless, markAsDone, } from '../services/components.js';
8
+ import { exitApp } from '../services/process.js';
4
9
  import { Column } from './Column.js';
5
- function exit(code) {
6
- setTimeout(() => globalThis.process.exit(code), 100);
7
- }
8
- function markAsDone(component) {
9
- return { ...component, state: { ...component.state, done: true } };
10
- }
11
- function createWelcomeDefinition(app) {
12
- return {
13
- name: 'welcome',
14
- props: { app },
15
- };
16
- }
17
- function createConfigSteps() {
18
- return [
19
- { description: 'Anthropic API key', key: 'key', value: null },
20
- {
21
- description: 'Model',
22
- key: 'model',
23
- value: 'claude-haiku-4-5-20251001',
24
- },
25
- ];
26
- }
27
- function createConfigDefinition(onFinished, onAborted) {
28
- return {
29
- name: 'config',
30
- state: { done: false },
31
- props: {
32
- steps: createConfigSteps(),
33
- onFinished,
34
- onAborted,
35
- },
36
- };
37
- }
38
- function createCommandDefinition(command, service, onError, onComplete) {
39
- return {
40
- name: 'command',
41
- state: {
42
- done: false,
43
- isLoading: true,
44
- },
45
- props: {
46
- command,
47
- service,
48
- onError,
49
- onComplete,
50
- },
51
- };
52
- }
53
- function createFeedback(type, ...messages) {
54
- return {
55
- name: 'feedback',
56
- props: {
57
- type,
58
- message: messages.join('\n\n'),
59
- },
60
- };
61
- }
62
- export const Main = ({ app, command, service: initialService, isReady, onConfigured, }) => {
63
- const [history, setHistory] = React.useState([]);
64
- const [current, setCurrent] = React.useState(null);
65
- const [service, setService] = React.useState(initialService);
66
- const addToHistory = React.useCallback((...items) => {
67
- setHistory((history) => [...history, ...items]);
68
- }, []);
69
- const handleConfigFinished = React.useCallback((config) => {
70
- const service = onConfigured?.(config);
71
- if (service) {
72
- setService(service);
10
+ export const Main = ({ app, command }) => {
11
+ // Initialize service from existing config if available
12
+ const [service, setService] = React.useState(() => {
13
+ if (hasValidAnthropicKey()) {
14
+ const config = loadConfig();
15
+ return createAnthropicService(config.anthropic);
73
16
  }
74
- // Move config to history with done state and add success feedback
75
- setCurrent((current) => {
76
- if (!current)
77
- return null;
78
- addToHistory(markAsDone(current), createFeedback(FeedbackType.Succeeded, 'Configuration complete'));
79
- return null;
80
- });
81
- }, [onConfigured, addToHistory]);
82
- const handleConfigAborted = React.useCallback(() => {
83
- // Move config to history with done state and add aborted feedback
84
- setCurrent((current) => {
85
- addToHistory(markAsDone(current), createFeedback(FeedbackType.Aborted, 'Configuration aborted by user'));
86
- // Exit after showing abort message
87
- exit(0);
88
- return null;
17
+ return null;
18
+ });
19
+ const [timeline, setTimeline] = React.useState([]);
20
+ const [queue, setQueue] = React.useState([]);
21
+ const addToTimeline = React.useCallback((...items) => {
22
+ setTimeline((timeline) => [...timeline, ...items]);
23
+ }, []);
24
+ const processNextInQueue = React.useCallback(() => {
25
+ setQueue((currentQueue) => {
26
+ if (currentQueue.length === 0)
27
+ return currentQueue;
28
+ const [first, ...rest] = currentQueue;
29
+ // Stateless components auto-complete immediately
30
+ if (isStateless(first)) {
31
+ addToTimeline(first);
32
+ return rest;
33
+ }
34
+ return currentQueue;
89
35
  });
90
- }, [addToHistory]);
36
+ }, [addToTimeline]);
91
37
  const handleCommandError = React.useCallback((error) => {
92
- // Move command to history with done state and add error feedback
93
- setCurrent((current) => {
94
- addToHistory(markAsDone(current), createFeedback(FeedbackType.Failed, 'Unexpected error occurred:', error));
95
- // Exit after showing error
96
- exit(1);
97
- return null;
38
+ setQueue((currentQueue) => {
39
+ if (currentQueue.length === 0)
40
+ return currentQueue;
41
+ const [first] = currentQueue;
42
+ if (first.name === ComponentName.Command) {
43
+ addToTimeline(markAsDone(first), createFeedback(FeedbackType.Failed, 'Unexpected error occurred:', error));
44
+ }
45
+ exitApp(1);
46
+ return [];
47
+ });
48
+ }, [addToTimeline]);
49
+ const handleCommandComplete = React.useCallback((message, tasks) => {
50
+ setQueue((currentQueue) => {
51
+ if (currentQueue.length === 0)
52
+ return currentQueue;
53
+ const [first] = currentQueue;
54
+ if (first.name === ComponentName.Command) {
55
+ addToTimeline(markAsDone(first), createPlanDefinition(message, tasks));
56
+ }
57
+ exitApp(0);
58
+ return [];
98
59
  });
99
- }, [addToHistory]);
100
- const handleCommandComplete = React.useCallback(() => {
101
- // Move command to history with done state
102
- setCurrent((current) => {
103
- addToHistory(markAsDone(current));
104
- // Exit after showing plan
105
- exit(0);
106
- return null;
60
+ }, [addToTimeline]);
61
+ const handleConfigFinished = React.useCallback((config) => {
62
+ const anthropicConfig = config;
63
+ saveAnthropicConfig(anthropicConfig);
64
+ const newService = createAnthropicService(anthropicConfig);
65
+ setService(newService);
66
+ // Complete config component and add command if present
67
+ setQueue((currentQueue) => {
68
+ if (currentQueue.length === 0)
69
+ return currentQueue;
70
+ const [first, ...rest] = currentQueue;
71
+ if (first.name === ComponentName.Config) {
72
+ addToTimeline(markAsDone(first), createFeedback(FeedbackType.Succeeded, 'Configuration complete'));
73
+ }
74
+ // Add command to queue if we have one
75
+ if (command) {
76
+ return [
77
+ ...rest,
78
+ createCommandDefinition(command, newService, handleCommandError, handleCommandComplete),
79
+ ];
80
+ }
81
+ // No command - exit after showing completion message
82
+ exitApp(0);
83
+ return rest;
84
+ });
85
+ }, [addToTimeline, command, handleCommandError, handleCommandComplete]);
86
+ const handleConfigAborted = React.useCallback(() => {
87
+ setQueue((currentQueue) => {
88
+ if (currentQueue.length === 0)
89
+ return currentQueue;
90
+ const [first] = currentQueue;
91
+ if (first.name === ComponentName.Config) {
92
+ addToTimeline(markAsDone(first), createFeedback(FeedbackType.Aborted, 'Configuration aborted by user'));
93
+ }
94
+ exitApp(0);
95
+ return [];
107
96
  });
108
- }, [addToHistory]);
109
- // Initialize configuration flow when not ready
97
+ }, [addToTimeline]);
98
+ // Initialize queue on mount
110
99
  React.useEffect(() => {
111
- if (isReady) {
112
- return;
100
+ const hasConfig = !!service;
101
+ if (command && hasConfig) {
102
+ // With command + valid config: [Command]
103
+ setQueue([
104
+ createCommandDefinition(command, service, handleCommandError, handleCommandComplete),
105
+ ]);
113
106
  }
114
- if (!command) {
115
- setHistory([createWelcomeDefinition(app)]);
107
+ else if (command && !hasConfig) {
108
+ // With command + no config: [Message, Config] (Command added after config)
109
+ setQueue([
110
+ createMessage(getConfigurationRequiredMessage()),
111
+ createConfigDefinition(handleConfigFinished, handleConfigAborted),
112
+ ]);
116
113
  }
117
- setCurrent(createConfigDefinition(handleConfigFinished, handleConfigAborted));
118
- }, [isReady, app, command, handleConfigFinished, handleConfigAborted]);
119
- // Execute command when service and command are available
120
- React.useEffect(() => {
121
- if (command && service) {
122
- setCurrent(createCommandDefinition(command, service, handleCommandError, handleCommandComplete));
114
+ else if (!command && hasConfig) {
115
+ // No command + valid config: [Welcome]
116
+ setQueue([createWelcomeDefinition(app)]);
123
117
  }
124
- }, [command, service, handleCommandError, handleCommandComplete]);
125
- // Show welcome screen when ready but no command
126
- React.useEffect(() => {
127
- if (isReady && !command) {
128
- setCurrent(createWelcomeDefinition(app));
118
+ else {
119
+ // No command + no config: [Welcome, Message, Config]
120
+ setQueue([
121
+ createWelcomeDefinition(app),
122
+ createMessage(getConfigurationRequiredMessage(true)),
123
+ createConfigDefinition(handleConfigFinished, handleConfigAborted),
124
+ ]);
129
125
  }
130
- }, [isReady, command, app]);
131
- const items = [...history, ...(current ? [current] : [])];
126
+ }, []); // Only run on mount
127
+ // Process queue whenever it changes
128
+ React.useEffect(() => {
129
+ processNextInQueue();
130
+ }, [queue, processNextInQueue]);
131
+ const current = queue.length > 0 ? queue[0] : null;
132
+ const items = [...timeline, ...(current ? [current] : [])];
132
133
  return _jsx(Column, { items: items });
133
134
  };
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ export const Message = ({ text }) => {
4
+ return _jsx(Text, { children: text });
5
+ };
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { TaskType } from '../types/components.js';
4
+ import { Label } from './Label.js';
5
+ import { List } from './List.js';
6
+ const ColorPalette = {
7
+ [TaskType.Config]: {
8
+ description: '#ffffff', // white
9
+ type: '#5c9ccc', // cyan
10
+ },
11
+ [TaskType.Plan]: {
12
+ description: '#ffffff', // white
13
+ type: '#5ccccc', // magenta
14
+ },
15
+ [TaskType.Execute]: {
16
+ description: '#ffffff', // white
17
+ type: '#4a9a7a', // green
18
+ },
19
+ [TaskType.Answer]: {
20
+ description: '#ffffff', // white
21
+ type: '#9c5ccc', // purple
22
+ },
23
+ [TaskType.Report]: {
24
+ description: '#ffffff', // white
25
+ type: '#cc9c5c', // orange
26
+ },
27
+ [TaskType.Define]: {
28
+ description: '#ffffff', // white
29
+ type: '#cc9c5c', // amber
30
+ },
31
+ [TaskType.Ignore]: {
32
+ description: '#cccc5c', // yellow
33
+ type: '#cc7a5c', // orange
34
+ },
35
+ [TaskType.Select]: {
36
+ description: '#888888', // grey
37
+ type: '#5c8cbc', // steel blue
38
+ },
39
+ };
40
+ function taskToListItem(task) {
41
+ const item = {
42
+ description: {
43
+ text: task.action,
44
+ color: ColorPalette[task.type].description,
45
+ },
46
+ type: { text: task.type, color: ColorPalette[task.type].type },
47
+ children: [],
48
+ };
49
+ // Add children for Define tasks with options
50
+ if (task.type === TaskType.Define && Array.isArray(task.params?.options)) {
51
+ const selectColors = ColorPalette[TaskType.Select];
52
+ item.children = task.params.options.map((option) => ({
53
+ description: {
54
+ text: String(option),
55
+ color: selectColors.description,
56
+ },
57
+ type: {
58
+ text: TaskType.Select,
59
+ color: selectColors.type,
60
+ },
61
+ }));
62
+ }
63
+ return item;
64
+ }
65
+ export function Plan({ message, tasks }) {
66
+ return (_jsxs(Box, { flexDirection: "column", children: [message && (_jsx(Box, { marginBottom: 1, children: _jsx(Label, { description: message, descriptionColor: ColorPalette[TaskType.Plan].description, type: TaskType.Plan, typeColor: ColorPalette[TaskType.Plan].type }) })), _jsx(List, { items: tasks.map(taskToListItem) })] }));
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prompt-language-shell",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Your personal command-line concierge. Ask politely, and it gets things done.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -57,6 +57,7 @@
57
57
  "@vitest/coverage-v8": "^4.0.8",
58
58
  "eslint": "^9.17.0",
59
59
  "husky": "^9.1.7",
60
+ "ink-testing-library": "^4.0.0",
60
61
  "prettier": "^3.6.2",
61
62
  "typescript": "^5.3.3",
62
63
  "typescript-eslint": "^8.19.1",