swiftroutercli 1.1.1 → 3.0.0

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/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # SwiftRouter CLI
2
+
3
+ The Official Command Line Interface for SwiftRouter API Gateway, built with React Ink.
4
+
5
+ ## V3 Features (Codex Parity)
6
+ - **Interactive Markdown Renderer**: Syntactically highlights Assistant markdown responses within the terminal.
7
+ - **Intelligent Bash Execution**: Automatically detects `bash` snippet blocks and prompts: `Execute suggested command? [Y/n]`.
8
+ - **RAG Local Workspaces**: Injects `process.cwd()` state and files directly into a system prompt context so the AI knows where you are.
9
+ - **Persistent History Storage**: Automatically saves conversation threads to `~/.swiftrouter-cli/history.json` and recalls them using up/down keys in the input.
10
+ - **Slash Commands & Menus**: Type `/models` to pop open a sleek `ink-select-input` menu fetched directly from the SwiftRouter `/v1/models` API endpoint. Navigate with up/down arrows!
11
+ - **System Commands**: Type `/clear` to reset the terminal history buffer, or `/exit`/`/quit` to cleanly exit the CLI.
12
+
13
+ ## Installation
14
+ ```bash
15
+ npm install -g swiftroutercli
16
+ ```
17
+
18
+ ## Configuration
19
+ Upon first launch, the CLI asks for configuration parameters. You can also configure via system ENV variables:
20
+ - `SWIFTROUTER_API_KEY`
21
+ - `SWIFTROUTER_BASE_URL` (Defaults to `http://localhost:3000`)
22
+
23
+ ## Usage
24
+ Simply run the CLI anywhere on your system:
25
+ ```bash
26
+ swiftrouter chat
27
+ ```
28
+
29
+ - Type `/models` to select models interactively.
30
+ - Type `/clear` to reset the session.
31
+ - Type `/exit` to quit.
32
+
33
+ ### Additional Commands
34
+ - `swiftrouter config --set-api-key <KEY> --set-base-url <URL>`: Manually configure CLI
35
+ - `swiftrouter models`: List available models natively
36
+ - `swiftrouter status`: Check authentication and connection status
37
+ - `swiftrouter logout`: Clear local configuration securely
38
+
39
+ ## Built With
40
+ - `ink`
41
+ - `marked-terminal`
42
+ - `ink-select-input`
43
+ - `eventsource-parser`
44
+
45
+ ## License
46
+ ISC
@@ -1,4 +1,22 @@
1
1
  import { createParser } from "eventsource-parser";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function getWorkspaceContext() {
5
+ const cwd = process.cwd();
6
+ let context = `Current Workspace: ${cwd}\n`;
7
+ const filesToRead = ["README.md", "AGENTS.md", "package.json"];
8
+ for (const file of filesToRead) {
9
+ const filePath = path.join(cwd, file);
10
+ if (fs.existsSync(filePath)) {
11
+ try {
12
+ const content = fs.readFileSync(filePath, "utf-8").slice(0, 1000);
13
+ context += `\n--- File: ${file} ---\n${content}\n`;
14
+ }
15
+ catch (e) { }
16
+ }
17
+ }
18
+ return context;
19
+ }
2
20
  export async function fetchModels(config) {
3
21
  const url = `${config.baseUrl.replace(/\/$/, "")}/v1/models`;
4
22
  const response = await fetch(url, {
@@ -12,8 +30,12 @@ export async function fetchModels(config) {
12
30
  const data = await response.json();
13
31
  return data.data || [];
14
32
  }
15
- export async function streamChatCompletion(config, model, prompt, onData, onComplete, onError) {
33
+ export async function streamChatCompletion(config, model, messages, onData, onComplete, onError) {
16
34
  const url = `${config.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
35
+ const payloadMessages = [...messages];
36
+ if (payloadMessages.length > 0 && payloadMessages[0].role !== "system") {
37
+ payloadMessages.unshift({ role: "system", content: getWorkspaceContext() });
38
+ }
17
39
  try {
18
40
  const response = await fetch(url, {
19
41
  method: "POST",
@@ -23,7 +45,7 @@ export async function streamChatCompletion(config, model, prompt, onData, onComp
23
45
  },
24
46
  body: JSON.stringify({
25
47
  model,
26
- messages: [{ role: "user", content: prompt }],
48
+ messages: payloadMessages,
27
49
  stream: true,
28
50
  }),
29
51
  });
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { CONFIG_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
3
+ import { CONFIG_FILE, HISTORY_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
4
4
  export function loadConfig() {
5
5
  // 1. Check for Environment Variables overriding everything else
6
6
  const envApiKey = process.env[ENV_API_KEY];
@@ -31,3 +31,20 @@ export function saveConfig(config) {
31
31
  }
32
32
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
33
33
  }
34
+ export function loadHistory() {
35
+ try {
36
+ if (fs.existsSync(HISTORY_FILE)) {
37
+ const data = fs.readFileSync(HISTORY_FILE, "utf-8");
38
+ return JSON.parse(data);
39
+ }
40
+ }
41
+ catch (error) { }
42
+ return [];
43
+ }
44
+ export function saveHistory(history) {
45
+ const dir = path.dirname(HISTORY_FILE);
46
+ if (!fs.existsSync(dir)) {
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ }
49
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8");
50
+ }
package/dist/constants.js CHANGED
@@ -6,6 +6,7 @@ export const DEFAULT_MODEL = "gpt-4o-mini";
6
6
  // System Paths
7
7
  export const CONFIG_DIR = path.join(os.homedir(), ".swiftrouter-cli");
8
8
  export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
9
+ export const HISTORY_FILE = path.join(CONFIG_DIR, "history.json");
9
10
  // Environment Variable Keys
10
11
  export const ENV_API_KEY = "SWIFTROUTER_API_KEY";
11
12
  export const ENV_BASE_URL = "SWIFTROUTER_BASE_URL";
package/dist/ui/Chat.js CHANGED
@@ -1,74 +1,186 @@
1
1
  import React, { useState, useEffect } from "react";
2
- import { render, Box, Text, useInput } from "ink";
3
- import { streamChatCompletion } from "../api/client.js";
2
+ import { render, Box, Text, useInput, Static } from "ink";
3
+ import { streamChatCompletion, fetchModels } from "../api/client.js";
4
+ import { loadHistory, saveHistory } from "../config.js";
5
+ import { marked } from "marked";
6
+ import TerminalRenderer from "marked-terminal";
7
+ import { exec } from "child_process";
8
+ import SelectInput from "ink-select-input";
9
+ marked.setOptions({
10
+ // @ts-ignore
11
+ renderer: new TerminalRenderer()
12
+ });
4
13
  const Chat = ({ config, model, initialPrompt }) => {
5
14
  const [messages, setMessages] = useState([
6
- { role: "user", content: initialPrompt },
15
+ { id: 0, role: "user", content: initialPrompt },
7
16
  ]);
8
17
  const [streamingContent, setStreamingContent] = useState("");
9
18
  const [isStreaming, setIsStreaming] = useState(true);
10
19
  const [error, setError] = useState(null);
11
20
  const [input, setInput] = useState("");
21
+ const [history, setHistory] = useState([]);
22
+ const [historyIndex, setHistoryIndex] = useState(-1);
23
+ const [pendingCommand, setPendingCommand] = useState(null);
24
+ const [isExecuting, setIsExecuting] = useState(false);
25
+ const [activeModel, setActiveModel] = useState(model);
26
+ const [isSelectingModel, setIsSelectingModel] = useState(false);
27
+ const [modelOptions, setModelOptions] = useState([]);
28
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
29
+ useEffect(() => {
30
+ setHistory(loadHistory());
31
+ }, []);
12
32
  useEffect(() => {
13
33
  if (!isStreaming)
14
34
  return;
15
- const lastUserMessage = messages[messages.length - 1];
16
- if (lastUserMessage.role !== "user")
17
- return;
18
- streamChatCompletion(config, model, lastUserMessage.content, (text) => setStreamingContent((prev) => prev + text), () => {
35
+ const messagesPayload = messages.map(m => ({ role: m.role, content: m.content }));
36
+ streamChatCompletion(config, activeModel, messagesPayload, (text) => setStreamingContent((prev) => prev + text), () => {
19
37
  setMessages((prev) => [
20
38
  ...prev,
21
- { role: "assistant", content: streamingContent },
39
+ { id: prev.length, role: "assistant", content: streamingContent },
22
40
  ]);
41
+ const bashMatch = streamingContent.match(/```(?:bash|sh)\n([\s\S]*?)\n```/);
42
+ if (bashMatch) {
43
+ setPendingCommand(bashMatch[1].trim());
44
+ }
23
45
  setStreamingContent("");
24
46
  setIsStreaming(false);
25
47
  }, (err) => {
26
48
  setError(err);
27
49
  setIsStreaming(false);
28
50
  });
29
- }, [messages, isStreaming, config, model]);
51
+ }, [messages, isStreaming, config, activeModel]);
30
52
  useInput((char, key) => {
53
+ if (isSelectingModel)
54
+ return;
31
55
  if (isStreaming)
32
56
  return;
57
+ if (pendingCommand && !isExecuting) {
58
+ if (char.toLowerCase() === "y" || key.return) {
59
+ setIsExecuting(true);
60
+ exec(pendingCommand, { cwd: process.cwd() }, (err, stdout, stderr) => {
61
+ const output = err ? stderr : stdout;
62
+ setMessages((prev) => [
63
+ ...prev,
64
+ { id: prev.length, role: "system", content: `Execution Output:\n${output || "Done"}` }
65
+ ]);
66
+ setPendingCommand(null);
67
+ setIsExecuting(false);
68
+ });
69
+ }
70
+ else if (char.toLowerCase() === "n" || key.escape) {
71
+ setPendingCommand(null);
72
+ }
73
+ return;
74
+ }
33
75
  if (key.return) {
34
- if (input.trim().length > 0) {
35
- setMessages((prev) => [...prev, { role: "user", content: input }]);
76
+ const trimmed = input.trim();
77
+ if (trimmed === "/exit" || trimmed === "/quit") {
78
+ process.exit(0);
79
+ }
80
+ if (trimmed === "/clear") {
81
+ setMessages([]);
36
82
  setInput("");
83
+ setHistoryIndex(-1);
84
+ return;
85
+ }
86
+ if (trimmed === "/models") {
87
+ setIsSelectingModel(true);
88
+ setIsLoadingModels(true);
89
+ setInput("");
90
+ fetchModels(config).then(models => {
91
+ const options = models.map(m => ({ label: m.id || m.name, value: m.id || m.name }));
92
+ setModelOptions(options.length ? options : [{ label: "mock-model", value: "mock-model" }]);
93
+ setIsLoadingModels(false);
94
+ }).catch(err => {
95
+ setError(err);
96
+ setIsSelectingModel(false);
97
+ setIsLoadingModels(false);
98
+ });
99
+ return;
100
+ }
101
+ if (trimmed.length > 0) {
102
+ const newPrompt = trimmed;
103
+ setMessages((prev) => [...prev, { id: prev.length, role: "user", content: newPrompt }]);
104
+ const newHistory = [...history, newPrompt];
105
+ setHistory(newHistory);
106
+ saveHistory(newHistory);
107
+ setInput("");
108
+ setHistoryIndex(-1);
37
109
  setIsStreaming(true);
38
110
  }
39
111
  }
112
+ else if (key.upArrow) {
113
+ if (history.length > 0 && historyIndex < history.length - 1) {
114
+ const newIdx = historyIndex + 1;
115
+ setHistoryIndex(newIdx);
116
+ setInput(history[history.length - 1 - newIdx]);
117
+ }
118
+ }
119
+ else if (key.downArrow) {
120
+ if (historyIndex > 0) {
121
+ const newIdx = historyIndex - 1;
122
+ setHistoryIndex(newIdx);
123
+ setInput(history[history.length - 1 - newIdx]);
124
+ }
125
+ else if (historyIndex === 0) {
126
+ setHistoryIndex(-1);
127
+ setInput("");
128
+ }
129
+ }
40
130
  else if (key.backspace || key.delete) {
41
131
  setInput((prev) => prev.slice(0, -1));
42
132
  }
43
- else {
133
+ else if (char) {
44
134
  setInput((prev) => prev + char);
45
135
  }
46
136
  });
47
- return (React.createElement(Box, { flexDirection: "column", padding: 1 },
48
- React.createElement(Box, { marginBottom: 1 },
49
- React.createElement(Text, { bold: true, color: "cyan" }, "SwiftRouter CLI"),
50
- React.createElement(Text, { dimColor: true },
51
- " - Chatting with ",
52
- model)),
53
- messages.map((msg, idx) => (React.createElement(Box, { key: idx, flexDirection: "column", marginBottom: 1 },
137
+ return (React.createElement(React.Fragment, null,
138
+ React.createElement(Static, { items: messages }, msg => (React.createElement(Box, { key: msg.id, flexDirection: "column", marginBottom: 1 },
54
139
  React.createElement(Text, { bold: true, color: msg.role === "user" ? "blue" : "green" },
55
140
  msg.role === "user" ? "You" : "Assistant",
56
141
  ":"),
57
- React.createElement(Text, null, msg.content)))),
58
- isStreaming && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
59
- React.createElement(Text, { bold: true, color: "green" }, "Assistant:"),
60
- React.createElement(Text, null, streamingContent),
61
- React.createElement(Text, { dimColor: true }, "..."))),
62
- error && (React.createElement(Box, null,
63
- React.createElement(Text, { color: "red" },
64
- "Error: ",
65
- error.message))),
66
- !isStreaming && (React.createElement(Box, null,
67
- React.createElement(Text, { bold: true, color: "blue" },
68
- "You:",
69
- " "),
70
- React.createElement(Text, null, input),
71
- React.createElement(Text, { dimColor: true }, " \u2588")))));
142
+ React.createElement(Text, null, msg.role === "user" ? msg.content : marked.parse(msg.content).trim())))),
143
+ React.createElement(Box, { flexDirection: "row", width: "100%", marginTop: 1 },
144
+ React.createElement(Box, { flexDirection: "column", width: "70%", paddingRight: 2 },
145
+ isStreaming && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
146
+ React.createElement(Text, { bold: true, color: "green" }, "Assistant:"),
147
+ React.createElement(Text, null, marked.parse(streamingContent).trim()),
148
+ React.createElement(Text, { dimColor: true }, "..."))),
149
+ isSelectingModel ? (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", padding: 1, marginTop: 1 },
150
+ React.createElement(Text, { color: "magenta", bold: true }, "Select Active Model"),
151
+ isLoadingModels ? (React.createElement(Text, { color: "yellow" }, "Fetching models from SwiftRouter API...")) : (
152
+ // @ts-ignore
153
+ React.createElement(SelectInput, { items: modelOptions, onSelect: (item) => {
154
+ setActiveModel(item.value);
155
+ setIsSelectingModel(false);
156
+ } })))) : (React.createElement(React.Fragment, null,
157
+ !isStreaming && !pendingCommand && !isExecuting && (React.createElement(Box, { flexDirection: "row" },
158
+ React.createElement(Text, { bold: true, color: "blue" }, "You: "),
159
+ React.createElement(Text, null, input),
160
+ React.createElement(Text, { dimColor: true }, " \u2588"))),
161
+ pendingCommand && !isExecuting && (React.createElement(Box, { flexDirection: "row", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1 },
162
+ React.createElement(Text, { color: "yellow" }, "Execute suggested command? "),
163
+ React.createElement(Text, { bold: true }, pendingCommand),
164
+ React.createElement(Text, { dimColor: true }, " [Y/n]"))),
165
+ isExecuting && (React.createElement(Box, { flexDirection: "row", marginTop: 1 },
166
+ React.createElement(Text, { bold: true, color: "yellow" }, "Executing command..."))))),
167
+ error && (React.createElement(Box, { marginTop: 1 },
168
+ React.createElement(Text, { color: "red" },
169
+ "Error: ",
170
+ error.message)))),
171
+ React.createElement(Box, { flexDirection: "column", width: "30%", borderStyle: "round", borderColor: "cyan", padding: 1, paddingX: 2 },
172
+ React.createElement(Text, { bold: true, color: "cyan", underline: true }, "SwiftRouter Context"),
173
+ React.createElement(Box, { marginY: 1, flexDirection: "column" },
174
+ React.createElement(Text, { color: "gray" }, "System Base URL:"),
175
+ React.createElement(Text, null, config.baseUrl)),
176
+ React.createElement(Box, { marginY: 1, flexDirection: "column" },
177
+ React.createElement(Text, { color: "gray" }, "Active Model:"),
178
+ React.createElement(Text, null, activeModel)),
179
+ React.createElement(Box, { marginY: 1, flexDirection: "column" },
180
+ React.createElement(Text, { color: "gray" }, "History Threads:"),
181
+ React.createElement(Text, null,
182
+ history.length,
183
+ " Prompts Saved"))))));
72
184
  };
73
185
  export function startChat(config, model, prompt) {
74
186
  render(React.createElement(Chat, { config: config, model: model, initialPrompt: prompt }));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "swiftroutercli",
3
- "version": "1.1.1",
4
- "description": "",
3
+ "version": "3.0.0",
4
+ "description": "The official SwiftRouter Command Line Interface using React Ink Components",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -16,11 +16,15 @@
16
16
  "author": "",
17
17
  "license": "ISC",
18
18
  "dependencies": {
19
+ "@types/marked-terminal": "^6.1.1",
19
20
  "chalk": "^4.1.2",
20
21
  "commander": "^14.0.3",
21
22
  "dotenv": "^17.3.1",
22
23
  "eventsource-parser": "^3.0.6",
23
24
  "ink": "^6.8.0",
25
+ "ink-select-input": "^6.2.0",
26
+ "marked": "^15.0.12",
27
+ "marked-terminal": "^7.3.0",
24
28
  "ora": "^5.4.1",
25
29
  "react": "^19.2.4",
26
30
  "terminal-image": "^4.2.0",
package/src/api/client.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import { Config, loadConfig } from "../config.js";
2
2
  import { createParser } from "eventsource-parser";
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ function getWorkspaceContext(): string {
7
+ const cwd = process.cwd();
8
+ let context = `Current Workspace: ${cwd}\n`;
9
+ const filesToRead = ["README.md", "AGENTS.md", "package.json"];
10
+ for (const file of filesToRead) {
11
+ const filePath = path.join(cwd, file);
12
+ if (fs.existsSync(filePath)) {
13
+ try {
14
+ const content = fs.readFileSync(filePath, "utf-8").slice(0, 1000);
15
+ context += `\n--- File: ${file} ---\n${content}\n`;
16
+ } catch (e) { }
17
+ }
18
+ }
19
+ return context;
20
+ }
21
+
3
22
 
4
23
  export async function fetchModels(config: Config): Promise<any[]> {
5
24
  const url = `${config.baseUrl.replace(/\/$/, "")}/v1/models`;
@@ -20,13 +39,18 @@ export async function fetchModels(config: Config): Promise<any[]> {
20
39
  export async function streamChatCompletion(
21
40
  config: Config,
22
41
  model: string,
23
- prompt: string,
42
+ messages: { role: string; content: string }[],
24
43
  onData: (text: string) => void,
25
44
  onComplete: () => void,
26
45
  onError: (error: Error) => void
27
46
  ) {
28
47
  const url = `${config.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
29
48
 
49
+ const payloadMessages = [...messages];
50
+ if (payloadMessages.length > 0 && payloadMessages[0].role !== "system") {
51
+ payloadMessages.unshift({ role: "system", content: getWorkspaceContext() });
52
+ }
53
+
30
54
  try {
31
55
  const response = await fetch(url, {
32
56
  method: "POST",
@@ -36,7 +60,7 @@ export async function streamChatCompletion(
36
60
  },
37
61
  body: JSON.stringify({
38
62
  model,
39
- messages: [{ role: "user", content: prompt }],
63
+ messages: payloadMessages,
40
64
  stream: true,
41
65
  }),
42
66
  });
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { CONFIG_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
3
+ import { CONFIG_FILE, HISTORY_FILE, ENV_API_KEY, ENV_BASE_URL } from "./constants.js";
4
4
 
5
5
  export interface Config {
6
6
  apiKey: string;
@@ -42,3 +42,21 @@ export function saveConfig(config: Config) {
42
42
  }
43
43
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
44
44
  }
45
+
46
+ export function loadHistory(): string[] {
47
+ try {
48
+ if (fs.existsSync(HISTORY_FILE)) {
49
+ const data = fs.readFileSync(HISTORY_FILE, "utf-8");
50
+ return JSON.parse(data) as string[];
51
+ }
52
+ } catch (error) { }
53
+ return [];
54
+ }
55
+
56
+ export function saveHistory(history: string[]) {
57
+ const dir = path.dirname(HISTORY_FILE);
58
+ if (!fs.existsSync(dir)) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8");
62
+ }
package/src/constants.ts CHANGED
@@ -8,6 +8,7 @@ export const DEFAULT_MODEL = "gpt-4o-mini";
8
8
  // System Paths
9
9
  export const CONFIG_DIR = path.join(os.homedir(), ".swiftrouter-cli");
10
10
  export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
11
+ export const HISTORY_FILE = path.join(CONFIG_DIR, "history.json");
11
12
 
12
13
  // Environment Variable Keys
13
14
  export const ENV_API_KEY = "SWIFTROUTER_API_KEY";
package/src/ui/Chat.tsx CHANGED
@@ -1,7 +1,21 @@
1
1
  import React, { useState, useEffect } from "react";
2
- import { render, Box, Text, useInput } from "ink";
3
- import { streamChatCompletion } from "../api/client.js";
4
- import { Config } from "../config.js";
2
+ import { render, Box, Text, useInput, Static } from "ink";
3
+ import { streamChatCompletion, fetchModels } from "../api/client.js";
4
+ import { Config, loadHistory, saveHistory } from "../config.js";
5
+ import { marked } from "marked";
6
+ import TerminalRenderer from "marked-terminal";
7
+ import { exec } from "child_process";
8
+ import SelectInput from "ink-select-input";
9
+
10
+ interface ModelOption {
11
+ label: string;
12
+ value: string;
13
+ }
14
+
15
+ marked.setOptions({
16
+ // @ts-ignore
17
+ renderer: new TerminalRenderer()
18
+ });
5
19
 
6
20
  interface ChatProps {
7
21
  config: Config;
@@ -10,30 +24,48 @@ interface ChatProps {
10
24
  }
11
25
 
12
26
  const Chat: React.FC<ChatProps> = ({ config, model, initialPrompt }) => {
13
- const [messages, setMessages] = useState<{ role: string; content: string }[]>([
14
- { role: "user", content: initialPrompt },
27
+ const [messages, setMessages] = useState<{ id: number; role: string; content: string }[]>([
28
+ { id: 0, role: "user", content: initialPrompt },
15
29
  ]);
16
30
  const [streamingContent, setStreamingContent] = useState("");
17
31
  const [isStreaming, setIsStreaming] = useState(true);
18
32
  const [error, setError] = useState<Error | null>(null);
19
33
  const [input, setInput] = useState("");
34
+ const [history, setHistory] = useState<string[]>([]);
35
+ const [historyIndex, setHistoryIndex] = useState(-1);
36
+ const [pendingCommand, setPendingCommand] = useState<string | null>(null);
37
+ const [isExecuting, setIsExecuting] = useState(false);
38
+
39
+ const [activeModel, setActiveModel] = useState(model);
40
+ const [isSelectingModel, setIsSelectingModel] = useState(false);
41
+ const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
42
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
43
+
44
+ useEffect(() => {
45
+ setHistory(loadHistory());
46
+ }, []);
20
47
 
21
48
  useEffect(() => {
22
49
  if (!isStreaming) return;
23
50
 
24
- const lastUserMessage = messages[messages.length - 1];
25
- if (lastUserMessage.role !== "user") return;
51
+ const messagesPayload = messages.map(m => ({ role: m.role, content: m.content }));
26
52
 
27
53
  streamChatCompletion(
28
54
  config,
29
- model,
30
- lastUserMessage.content,
55
+ activeModel,
56
+ messagesPayload,
31
57
  (text) => setStreamingContent((prev) => prev + text),
32
58
  () => {
33
59
  setMessages((prev) => [
34
60
  ...prev,
35
- { role: "assistant", content: streamingContent },
61
+ { id: prev.length, role: "assistant", content: streamingContent },
36
62
  ]);
63
+
64
+ const bashMatch = streamingContent.match(/```(?:bash|sh)\n([\s\S]*?)\n```/);
65
+ if (bashMatch) {
66
+ setPendingCommand(bashMatch[1].trim());
67
+ }
68
+
37
69
  setStreamingContent("");
38
70
  setIsStreaming(false);
39
71
  },
@@ -42,68 +74,180 @@ const Chat: React.FC<ChatProps> = ({ config, model, initialPrompt }) => {
42
74
  setIsStreaming(false);
43
75
  }
44
76
  );
45
- }, [messages, isStreaming, config, model]);
77
+ }, [messages, isStreaming, config, activeModel]);
46
78
 
47
79
  useInput((char: string, key: any) => {
80
+ if (isSelectingModel) return;
48
81
  if (isStreaming) return;
49
82
 
83
+ if (pendingCommand && !isExecuting) {
84
+ if (char.toLowerCase() === "y" || key.return) {
85
+ setIsExecuting(true);
86
+ exec(pendingCommand, { cwd: process.cwd() }, (err, stdout, stderr) => {
87
+ const output = err ? stderr : stdout;
88
+ setMessages((prev) => [
89
+ ...prev,
90
+ { id: prev.length, role: "system", content: `Execution Output:\n${output || "Done"}` }
91
+ ]);
92
+ setPendingCommand(null);
93
+ setIsExecuting(false);
94
+ });
95
+ } else if (char.toLowerCase() === "n" || key.escape) {
96
+ setPendingCommand(null);
97
+ }
98
+ return;
99
+ }
100
+
50
101
  if (key.return) {
51
- if (input.trim().length > 0) {
52
- setMessages((prev) => [...prev, { role: "user", content: input }]);
102
+ const trimmed = input.trim();
103
+ if (trimmed === "/exit" || trimmed === "/quit") {
104
+ process.exit(0);
105
+ }
106
+ if (trimmed === "/clear") {
107
+ setMessages([]);
108
+ setInput("");
109
+ setHistoryIndex(-1);
110
+ return;
111
+ }
112
+ if (trimmed === "/models") {
113
+ setIsSelectingModel(true);
114
+ setIsLoadingModels(true);
115
+ setInput("");
116
+ fetchModels(config).then(models => {
117
+ const options = models.map(m => ({ label: m.id || m.name, value: m.id || m.name }));
118
+ setModelOptions(options.length ? options : [{ label: "mock-model", value: "mock-model" }]);
119
+ setIsLoadingModels(false);
120
+ }).catch(err => {
121
+ setError(err);
122
+ setIsSelectingModel(false);
123
+ setIsLoadingModels(false);
124
+ });
125
+ return;
126
+ }
127
+
128
+ if (trimmed.length > 0) {
129
+ const newPrompt = trimmed;
130
+ setMessages((prev) => [...prev, { id: prev.length, role: "user", content: newPrompt }]);
131
+
132
+ const newHistory = [...history, newPrompt];
133
+ setHistory(newHistory);
134
+ saveHistory(newHistory);
135
+
53
136
  setInput("");
137
+ setHistoryIndex(-1);
54
138
  setIsStreaming(true);
55
139
  }
140
+ } else if (key.upArrow) {
141
+ if (history.length > 0 && historyIndex < history.length - 1) {
142
+ const newIdx = historyIndex + 1;
143
+ setHistoryIndex(newIdx);
144
+ setInput(history[history.length - 1 - newIdx]);
145
+ }
146
+ } else if (key.downArrow) {
147
+ if (historyIndex > 0) {
148
+ const newIdx = historyIndex - 1;
149
+ setHistoryIndex(newIdx);
150
+ setInput(history[history.length - 1 - newIdx]);
151
+ } else if (historyIndex === 0) {
152
+ setHistoryIndex(-1);
153
+ setInput("");
154
+ }
56
155
  } else if (key.backspace || key.delete) {
57
156
  setInput((prev) => prev.slice(0, -1));
58
- } else {
157
+ } else if (char) {
59
158
  setInput((prev) => prev + char);
60
159
  }
61
160
  });
62
161
 
63
162
  return (
64
- <Box flexDirection="column" padding={1}>
65
- <Box marginBottom={1}>
66
- <Text bold color="cyan">
67
- SwiftRouter CLI
68
- </Text>
69
- <Text dimColor> - Chatting with {model}</Text>
70
- </Box>
163
+ <>
164
+ <Static items={messages}>
165
+ {msg => (
166
+ <Box key={msg.id} flexDirection="column" marginBottom={1}>
167
+ <Text bold color={msg.role === "user" ? "blue" : "green"}>
168
+ {msg.role === "user" ? "You" : "Assistant"}:
169
+ </Text>
170
+ <Text>{msg.role === "user" ? msg.content : (marked.parse(msg.content) as string).trim()}</Text>
171
+ </Box>
172
+ )}
173
+ </Static>
71
174
 
72
- {messages.map((msg, idx) => (
73
- <Box key={idx} flexDirection="column" marginBottom={1}>
74
- <Text bold color={msg.role === "user" ? "blue" : "green"}>
75
- {msg.role === "user" ? "You" : "Assistant"}:
76
- </Text>
77
- <Text>{msg.content}</Text>
78
- </Box>
79
- ))}
80
-
81
- {isStreaming && (
82
- <Box flexDirection="column" marginBottom={1}>
83
- <Text bold color="green">
84
- Assistant:
85
- </Text>
86
- <Text>{streamingContent}</Text>
87
- <Text dimColor>...</Text>
88
- </Box>
89
- )}
175
+ <Box flexDirection="row" width="100%" marginTop={1}>
176
+ <Box flexDirection="column" width="70%" paddingRight={2}>
177
+ {isStreaming && (
178
+ <Box flexDirection="column" marginBottom={1}>
179
+ <Text bold color="green">Assistant:</Text>
180
+ <Text>{(marked.parse(streamingContent) as string).trim()}</Text>
181
+ <Text dimColor>...</Text>
182
+ </Box>
183
+ )}
184
+
185
+ {isSelectingModel ? (
186
+ <Box flexDirection="column" borderStyle="round" borderColor="magenta" padding={1} marginTop={1}>
187
+ <Text color="magenta" bold>Select Active Model</Text>
188
+ {isLoadingModels ? (
189
+ <Text color="yellow">Fetching models from SwiftRouter API...</Text>
190
+ ) : (
191
+ // @ts-ignore
192
+ <SelectInput
193
+ items={modelOptions}
194
+ onSelect={(item: any) => {
195
+ setActiveModel(item.value);
196
+ setIsSelectingModel(false);
197
+ }}
198
+ />
199
+ )}
200
+ </Box>
201
+ ) : (
202
+ <>
203
+ {!isStreaming && !pendingCommand && !isExecuting && (
204
+ <Box flexDirection="row">
205
+ <Text bold color="blue">You: </Text>
206
+ <Text>{input}</Text>
207
+ <Text dimColor> █</Text>
208
+ </Box>
209
+ )}
210
+
211
+ {pendingCommand && !isExecuting && (
212
+ <Box flexDirection="row" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
213
+ <Text color="yellow">Execute suggested command? </Text>
214
+ <Text bold>{pendingCommand}</Text>
215
+ <Text dimColor> [Y/n]</Text>
216
+ </Box>
217
+ )}
90
218
 
91
- {error && (
92
- <Box>
93
- <Text color="red">Error: {error.message}</Text>
219
+ {isExecuting && (
220
+ <Box flexDirection="row" marginTop={1}>
221
+ <Text bold color="yellow">Executing command...</Text>
222
+ </Box>
223
+ )}
224
+ </>
225
+ )}
226
+
227
+ {error && (
228
+ <Box marginTop={1}>
229
+ <Text color="red">Error: {error.message}</Text>
230
+ </Box>
231
+ )}
94
232
  </Box>
95
- )}
96
-
97
- {!isStreaming && (
98
- <Box>
99
- <Text bold color="blue">
100
- You:{" "}
101
- </Text>
102
- <Text>{input}</Text>
103
- <Text dimColor> █</Text>
233
+
234
+ <Box flexDirection="column" width="30%" borderStyle="round" borderColor="cyan" padding={1} paddingX={2}>
235
+ <Text bold color="cyan" underline>SwiftRouter Context</Text>
236
+ <Box marginY={1} flexDirection="column">
237
+ <Text color="gray">System Base URL:</Text>
238
+ <Text>{config.baseUrl}</Text>
239
+ </Box>
240
+ <Box marginY={1} flexDirection="column">
241
+ <Text color="gray">Active Model:</Text>
242
+ <Text>{activeModel}</Text>
243
+ </Box>
244
+ <Box marginY={1} flexDirection="column">
245
+ <Text color="gray">History Threads:</Text>
246
+ <Text>{history.length} Prompts Saved</Text>
247
+ </Box>
104
248
  </Box>
105
- )}
106
- </Box>
249
+ </Box>
250
+ </>
107
251
  );
108
252
  };
109
253