@cdoing/opentuicli 0.1.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 +48 -0
- package/dist/index.js.map +7 -0
- package/esbuild.config.cjs +44 -0
- package/package.json +34 -0
- package/src/app.tsx +566 -0
- package/src/components/dialog-command.tsx +204 -0
- package/src/components/dialog-help.tsx +227 -0
- package/src/components/dialog-model.tsx +93 -0
- package/src/components/dialog-status.tsx +122 -0
- package/src/components/dialog-theme.tsx +292 -0
- package/src/components/input-area.tsx +318 -0
- package/src/components/loading-spinner.tsx +28 -0
- package/src/components/message-list.tsx +338 -0
- package/src/components/permission-prompt.tsx +71 -0
- package/src/components/session-browser.tsx +220 -0
- package/src/components/session-footer.tsx +30 -0
- package/src/components/session-header.tsx +39 -0
- package/src/components/setup-wizard.tsx +463 -0
- package/src/components/sidebar.tsx +130 -0
- package/src/components/status-bar.tsx +76 -0
- package/src/components/toast.tsx +139 -0
- package/src/context/sdk.tsx +40 -0
- package/src/context/theme.tsx +532 -0
- package/src/index.ts +50 -0
- package/src/lib/autocomplete.ts +258 -0
- package/src/lib/context-providers.ts +98 -0
- package/src/lib/history.ts +164 -0
- package/src/lib/terminal-title.ts +15 -0
- package/src/routes/home.tsx +148 -0
- package/src/routes/session.tsx +1186 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete engine — slash commands, @mentions, path completion, tool subcommands
|
|
3
|
+
*
|
|
4
|
+
* Ported from @cdoing/cli UserInput.tsx logic for the OpenTUI terminal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
|
|
10
|
+
// ── Slash Commands ────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface SlashCommand {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SLASH_COMMANDS: SlashCommand[] = [
|
|
18
|
+
{ name: "/help", description: "Show available commands" },
|
|
19
|
+
{ name: "/clear", description: "Clear chat history" },
|
|
20
|
+
{ name: "/new", description: "Start new conversation" },
|
|
21
|
+
{ name: "/model", description: "Show/change model" },
|
|
22
|
+
{ name: "/provider", description: "Show/change provider" },
|
|
23
|
+
{ name: "/mode", description: "Cycle permission mode" },
|
|
24
|
+
{ name: "/usage", description: "Show token usage & cost" },
|
|
25
|
+
{ name: "/compact", description: "Compress context" },
|
|
26
|
+
{ name: "/config", description: "Show/set configuration" },
|
|
27
|
+
{ name: "/dir", description: "Change working directory" },
|
|
28
|
+
{ name: "/history", description: "Browse past sessions" },
|
|
29
|
+
{ name: "/ls", description: "List conversations" },
|
|
30
|
+
{ name: "/resume", description: "Resume conversation by ID" },
|
|
31
|
+
{ name: "/view", description: "View a conversation" },
|
|
32
|
+
{ name: "/fork", description: "Fork current conversation" },
|
|
33
|
+
{ name: "/delete", description: "Delete a conversation" },
|
|
34
|
+
{ name: "/plan", description: "Toggle plan mode" },
|
|
35
|
+
{ name: "/tasks", description: "Show active tasks" },
|
|
36
|
+
{ name: "/memory", description: "Show agent memory" },
|
|
37
|
+
{ name: "/permissions", description: "Show permission rules" },
|
|
38
|
+
{ name: "/hooks", description: "Show configured hooks" },
|
|
39
|
+
{ name: "/rules", description: "Show project rules" },
|
|
40
|
+
{ name: "/mcp", description: "MCP server management" },
|
|
41
|
+
{ name: "/context", description: "Show context providers" },
|
|
42
|
+
{ name: "/effort", description: "Set effort level" },
|
|
43
|
+
{ name: "/theme", description: "Switch theme" },
|
|
44
|
+
{ name: "/bg", description: "Run in background" },
|
|
45
|
+
{ name: "/jobs", description: "Show background jobs" },
|
|
46
|
+
{ name: "/login", description: "OAuth login" },
|
|
47
|
+
{ name: "/logout", description: "OAuth logout" },
|
|
48
|
+
{ name: "/setup", description: "Run setup wizard" },
|
|
49
|
+
{ name: "/doctor", description: "Check system health" },
|
|
50
|
+
{ name: "/init", description: "Initialize project config" },
|
|
51
|
+
{ name: "/exit", description: "Quit the TUI" },
|
|
52
|
+
{ name: "/quit", description: "Quit the TUI" },
|
|
53
|
+
{ name: "/btw", description: "Ask without adding to history" },
|
|
54
|
+
{ name: "/auth-status", description: "Show authentication status" },
|
|
55
|
+
{ name: "/queue", description: "Show message queue" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ── @Mention Providers ────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface MentionProvider {
|
|
61
|
+
trigger: string;
|
|
62
|
+
description: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const MENTION_PROVIDERS: MentionProvider[] = [
|
|
66
|
+
{ trigger: "@terminal", description: "Recent terminal output" },
|
|
67
|
+
{ trigger: "@url", description: "Fetch URL content" },
|
|
68
|
+
{ trigger: "@tree", description: "Project file tree" },
|
|
69
|
+
{ trigger: "@codebase", description: "Full codebase context" },
|
|
70
|
+
{ trigger: "@clip", description: "Clipboard content" },
|
|
71
|
+
{ trigger: "@file", description: "Include a file" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// ── Tool Subcommands ──────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const TOOL_SUBCOMMANDS: Record<string, string[]> = {
|
|
77
|
+
npm: ["install", "run", "test", "start", "build", "init", "publish", "uninstall", "update", "ls", "audit", "ci"],
|
|
78
|
+
yarn: ["install", "add", "remove", "run", "build", "test", "start", "dev", "upgrade", "info", "why"],
|
|
79
|
+
pnpm: ["install", "add", "remove", "run", "build", "test", "dev", "update", "store"],
|
|
80
|
+
bun: ["install", "add", "remove", "run", "build", "test", "dev", "init", "create"],
|
|
81
|
+
git: ["status", "add", "commit", "push", "pull", "fetch", "checkout", "branch", "merge", "rebase", "log", "diff", "stash", "reset", "clone", "remote", "tag", "cherry-pick", "bisect", "show", "blame", "reflog"],
|
|
82
|
+
docker: ["build", "run", "exec", "ps", "images", "pull", "push", "stop", "rm", "logs", "compose"],
|
|
83
|
+
python: ["-m", "-c", "--version", "manage.py"],
|
|
84
|
+
python3: ["-m", "-c", "--version", "manage.py"],
|
|
85
|
+
pip: ["install", "uninstall", "freeze", "list", "show"],
|
|
86
|
+
cargo: ["build", "run", "test", "check", "clippy", "fmt", "new", "init", "add", "publish"],
|
|
87
|
+
go: ["build", "run", "test", "get", "mod", "fmt", "vet", "install", "generate"],
|
|
88
|
+
make: ["build", "test", "clean", "install", "all"],
|
|
89
|
+
kubectl: ["get", "describe", "apply", "delete", "logs", "exec", "port-forward", "scale"],
|
|
90
|
+
gh: ["pr", "issue", "repo", "run", "release", "api", "auth", "browse"],
|
|
91
|
+
turbo: ["build", "dev", "test", "lint", "run"],
|
|
92
|
+
npx: ["tsc", "ts-node", "eslint", "prettier", "jest", "vitest", "playwright"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── Suggestion Types ──────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface Suggestion {
|
|
98
|
+
text: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
type: "command" | "mention" | "file" | "subcommand";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Autocomplete Logic ────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export function getCompletions(input: string, workingDir: string): Suggestion[] {
|
|
106
|
+
if (!input) return [];
|
|
107
|
+
|
|
108
|
+
// Slash commands
|
|
109
|
+
if (input.startsWith("/")) {
|
|
110
|
+
return SLASH_COMMANDS
|
|
111
|
+
.filter((c) => c.name.startsWith(input))
|
|
112
|
+
.map((c) => ({ text: c.name, description: c.description, type: "command" as const }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// @mentions
|
|
116
|
+
if (input.startsWith("@") || input.includes(" @")) {
|
|
117
|
+
const atIdx = input.lastIndexOf("@");
|
|
118
|
+
const query = input.substring(atIdx);
|
|
119
|
+
|
|
120
|
+
const results: Suggestion[] = [];
|
|
121
|
+
|
|
122
|
+
// Provider matches
|
|
123
|
+
for (const p of MENTION_PROVIDERS) {
|
|
124
|
+
if (p.trigger.startsWith(query)) {
|
|
125
|
+
results.push({ text: p.trigger, description: p.description, type: "mention" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// File matches for @file or bare @
|
|
130
|
+
if (query === "@" || query.startsWith("@file ") || query.startsWith("@f")) {
|
|
131
|
+
const fileQuery = query.startsWith("@file ") ? query.substring(6) : "";
|
|
132
|
+
const files = getProjectFiles(workingDir, fileQuery);
|
|
133
|
+
for (const f of files.slice(0, 10)) {
|
|
134
|
+
results.push({ text: `@file ${f}`, description: "", type: "file" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Tool subcommands (e.g. "npm " → show npm subcommands)
|
|
142
|
+
const parts = input.split(" ");
|
|
143
|
+
if (parts.length >= 1) {
|
|
144
|
+
const tool = parts[0];
|
|
145
|
+
const subcommands = TOOL_SUBCOMMANDS[tool];
|
|
146
|
+
if (subcommands && parts.length <= 2) {
|
|
147
|
+
const sub = parts[1] || "";
|
|
148
|
+
return subcommands
|
|
149
|
+
.filter((s) => s.startsWith(sub))
|
|
150
|
+
.map((s) => ({ text: `${tool} ${s}`, description: "", type: "subcommand" as const }));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Path completion for shell commands (cd, ls, cat, etc.)
|
|
155
|
+
if (parts.length >= 2) {
|
|
156
|
+
const pathResults = getPathCompletions(input, workingDir);
|
|
157
|
+
if (pathResults.length > 0) {
|
|
158
|
+
const prefix = parts.slice(0, -1).join(" ") + " ";
|
|
159
|
+
return pathResults.map((p) => ({
|
|
160
|
+
text: prefix + p,
|
|
161
|
+
description: p.endsWith("/") ? "directory" : "file",
|
|
162
|
+
type: "file" as const,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Ghost Text ────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export function getGhostText(input: string, workingDir: string): string {
|
|
173
|
+
if (!input) return "";
|
|
174
|
+
|
|
175
|
+
// Slash command ghost
|
|
176
|
+
if (input.startsWith("/")) {
|
|
177
|
+
const match = SLASH_COMMANDS.find((c) => c.name.startsWith(input) && c.name !== input);
|
|
178
|
+
return match ? match.name.substring(input.length) : "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// @mention ghost
|
|
182
|
+
const atIdx = input.lastIndexOf("@");
|
|
183
|
+
if (atIdx >= 0 && atIdx === input.length - input.substring(atIdx).length) {
|
|
184
|
+
const query = input.substring(atIdx);
|
|
185
|
+
const match = MENTION_PROVIDERS.find((p) => p.trigger.startsWith(query) && p.trigger !== query);
|
|
186
|
+
return match ? match.trigger.substring(query.length) : "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Path Completion ───────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export function getPathCompletions(input: string, workingDir: string): string[] {
|
|
195
|
+
// Detect path context: commands like cd, ls, cat, etc.
|
|
196
|
+
const pathCommands = ["cd", "ls", "cat", "head", "tail", "less", "more", "vim", "nano", "code", "open", "rm", "cp", "mv", "mkdir", "touch", "chmod"];
|
|
197
|
+
const parts = input.split(" ");
|
|
198
|
+
if (parts.length < 2) return [];
|
|
199
|
+
|
|
200
|
+
const cmd = parts[0];
|
|
201
|
+
if (!pathCommands.includes(cmd)) return [];
|
|
202
|
+
|
|
203
|
+
const partial = parts[parts.length - 1] || "";
|
|
204
|
+
return readPathEntries(workingDir, partial);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readPathEntries(workingDir: string, partial: string): string[] {
|
|
208
|
+
try {
|
|
209
|
+
const dir = partial.includes("/")
|
|
210
|
+
? path.resolve(workingDir, partial.substring(0, partial.lastIndexOf("/") + 1))
|
|
211
|
+
: workingDir;
|
|
212
|
+
const prefix = partial.includes("/") ? partial.substring(partial.lastIndexOf("/") + 1) : partial;
|
|
213
|
+
|
|
214
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
|
|
215
|
+
|
|
216
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
217
|
+
const base = partial.includes("/") ? partial.substring(0, partial.lastIndexOf("/") + 1) : "";
|
|
218
|
+
|
|
219
|
+
return entries
|
|
220
|
+
.filter((e) => !e.name.startsWith(".") || partial.startsWith("."))
|
|
221
|
+
.filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
222
|
+
.sort((a, b) => {
|
|
223
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
224
|
+
return a.name.localeCompare(b.name);
|
|
225
|
+
})
|
|
226
|
+
.slice(0, 20)
|
|
227
|
+
.map((e) => base + e.name + (e.isDirectory() ? "/" : ""));
|
|
228
|
+
} catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getProjectFiles(workingDir: string, query: string): string[] {
|
|
234
|
+
const results: string[] = [];
|
|
235
|
+
const lower = query.toLowerCase();
|
|
236
|
+
const skipDirs = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".cache", "coverage"]);
|
|
237
|
+
|
|
238
|
+
const walk = (dir: string, rel: string, depth: number) => {
|
|
239
|
+
if (depth > 3 || results.length >= 15) return;
|
|
240
|
+
try {
|
|
241
|
+
const entries = fs.readdirSync(path.join(workingDir, dir), { withFileTypes: true });
|
|
242
|
+
for (const e of entries) {
|
|
243
|
+
if (e.name.startsWith(".") || skipDirs.has(e.name)) continue;
|
|
244
|
+
if (results.length >= 15) break;
|
|
245
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
246
|
+
if (e.isDirectory()) {
|
|
247
|
+
if (!query || p.toLowerCase().includes(lower)) results.push(p + "/");
|
|
248
|
+
walk(path.join(dir, e.name), p, depth + 1);
|
|
249
|
+
} else {
|
|
250
|
+
if (!query || p.toLowerCase().includes(lower)) results.push(p);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch { /* skip */ }
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
walk("", "", 0);
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Provider Expansion — resolves @mentions in user messages
|
|
3
|
+
*
|
|
4
|
+
* Detects @terminal, @url, @tree, @codebase, @clip, @file triggers
|
|
5
|
+
* and expands them into the actual content before sending to the agent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ContextProviderRegistry,
|
|
10
|
+
TerminalContextProvider,
|
|
11
|
+
UrlContextProvider,
|
|
12
|
+
TreeContextProvider,
|
|
13
|
+
CodebaseContextProvider,
|
|
14
|
+
ClipboardContextProvider,
|
|
15
|
+
FileIncludeContextProvider,
|
|
16
|
+
} from "@cdoing/core";
|
|
17
|
+
|
|
18
|
+
let registry: ContextProviderRegistry | null = null;
|
|
19
|
+
let terminalProvider: TerminalContextProvider | null = null;
|
|
20
|
+
|
|
21
|
+
function getRegistry(): ContextProviderRegistry {
|
|
22
|
+
if (!registry) {
|
|
23
|
+
registry = new ContextProviderRegistry();
|
|
24
|
+
terminalProvider = new TerminalContextProvider();
|
|
25
|
+
registry.register(terminalProvider);
|
|
26
|
+
registry.register(new UrlContextProvider());
|
|
27
|
+
registry.register(new TreeContextProvider());
|
|
28
|
+
registry.register(new CodebaseContextProvider());
|
|
29
|
+
registry.register(new ClipboardContextProvider());
|
|
30
|
+
registry.register(new FileIncludeContextProvider());
|
|
31
|
+
}
|
|
32
|
+
return registry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Update terminal provider with recent shell output */
|
|
36
|
+
export function pushTerminalOutput(output: string): void {
|
|
37
|
+
getRegistry();
|
|
38
|
+
if (terminalProvider && typeof (terminalProvider as any).push === "function") {
|
|
39
|
+
(terminalProvider as any).push(output);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Known @mention triggers */
|
|
44
|
+
const TRIGGERS = ["@terminal", "@url", "@tree", "@codebase", "@clip", "@file"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve all @mention providers in a message.
|
|
48
|
+
* Returns the expanded message with provider content appended.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveContextProviders(
|
|
51
|
+
message: string,
|
|
52
|
+
workingDir: string
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const reg = getRegistry();
|
|
55
|
+
let expandedMessage = message;
|
|
56
|
+
const appendSections: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const trigger of TRIGGERS) {
|
|
59
|
+
const idx = expandedMessage.indexOf(trigger);
|
|
60
|
+
if (idx === -1) continue;
|
|
61
|
+
|
|
62
|
+
// Extract the trigger + argument
|
|
63
|
+
const afterTrigger = expandedMessage.substring(idx + trigger.length);
|
|
64
|
+
const endIdx = afterTrigger.search(/\s@|\n|$/);
|
|
65
|
+
const arg = afterTrigger.substring(0, endIdx === -1 ? afterTrigger.length : endIdx).trim();
|
|
66
|
+
|
|
67
|
+
// Remove trigger from message
|
|
68
|
+
const fullTrigger = trigger + (arg ? " " + arg : "");
|
|
69
|
+
expandedMessage = expandedMessage.replace(fullTrigger, "").trim();
|
|
70
|
+
|
|
71
|
+
// Resolve
|
|
72
|
+
const providerName = trigger.substring(1); // strip @
|
|
73
|
+
const provider = reg.get(providerName);
|
|
74
|
+
if (!provider) continue;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await provider.resolve(arg, { workingDir });
|
|
78
|
+
if (result) {
|
|
79
|
+
appendSections.push(`--- ${trigger} ---\n${result}`);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Silently skip failed providers
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (appendSections.length > 0) {
|
|
87
|
+
expandedMessage = expandedMessage + "\n\n" + appendSections.join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return expandedMessage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if a message contains any @mention triggers.
|
|
95
|
+
*/
|
|
96
|
+
export function hasContextMentions(message: string): boolean {
|
|
97
|
+
return TRIGGERS.some((t) => message.includes(t));
|
|
98
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation History Manager for TUI
|
|
3
|
+
*
|
|
4
|
+
* Saves conversations to ~/.cdoing/conversations/ as JSON files.
|
|
5
|
+
* Mirrors the base CLI implementation for full compatibility.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
|
|
12
|
+
const CONV_DIR = path.join(os.homedir(), ".cdoing", "conversations");
|
|
13
|
+
|
|
14
|
+
export interface ChatMessage {
|
|
15
|
+
role: "user" | "assistant" | "tool";
|
|
16
|
+
content: string;
|
|
17
|
+
toolName?: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Conversation {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
messages: ChatMessage[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDir(): void {
|
|
32
|
+
if (!fs.existsSync(CONV_DIR)) fs.mkdirSync(CONV_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generateId(): string {
|
|
36
|
+
const now = Date.now().toString(36);
|
|
37
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
38
|
+
return `${now}-${rand}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function deriveTitle(message: string): string {
|
|
42
|
+
const clean = message.replace(/\n/g, " ").trim();
|
|
43
|
+
return clean.length > 60 ? clean.substring(0, 57) + "..." : clean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createConversation(provider: string, model: string): Conversation {
|
|
47
|
+
ensureDir();
|
|
48
|
+
return {
|
|
49
|
+
id: generateId(),
|
|
50
|
+
title: "New conversation",
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
updatedAt: Date.now(),
|
|
53
|
+
provider,
|
|
54
|
+
model,
|
|
55
|
+
messages: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function addMessage(
|
|
60
|
+
conv: Conversation,
|
|
61
|
+
role: "user" | "assistant" | "tool",
|
|
62
|
+
content: string,
|
|
63
|
+
toolName?: string
|
|
64
|
+
): void {
|
|
65
|
+
conv.messages.push({ role, content, timestamp: Date.now(), toolName });
|
|
66
|
+
conv.updatedAt = Date.now();
|
|
67
|
+
|
|
68
|
+
if (role === "user" && conv.title === "New conversation") {
|
|
69
|
+
conv.title = deriveTitle(content);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
saveConversation(conv);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function saveConversation(conv: Conversation): void {
|
|
76
|
+
ensureDir();
|
|
77
|
+
const filePath = path.join(CONV_DIR, `${conv.id}.json`);
|
|
78
|
+
fs.writeFileSync(filePath, JSON.stringify(conv, null, 2), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadConversation(id: string): Conversation | null {
|
|
82
|
+
// Support partial IDs
|
|
83
|
+
ensureDir();
|
|
84
|
+
const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
|
|
85
|
+
const match = files.find((f) => f.startsWith(id) || f.replace(".json", "") === id);
|
|
86
|
+
if (!match) return null;
|
|
87
|
+
|
|
88
|
+
const filePath = path.join(CONV_DIR, match);
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listConversations(): Conversation[] {
|
|
97
|
+
ensureDir();
|
|
98
|
+
const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
|
|
99
|
+
const convs: Conversation[] = [];
|
|
100
|
+
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
try {
|
|
103
|
+
const data = JSON.parse(fs.readFileSync(path.join(CONV_DIR, file), "utf-8"));
|
|
104
|
+
convs.push(data);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return convs.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadLastConversation(): Conversation | null {
|
|
112
|
+
const all = listConversations();
|
|
113
|
+
return all.length > 0 ? all[0] : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function deleteConversation(id: string): boolean {
|
|
117
|
+
ensureDir();
|
|
118
|
+
const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
|
|
119
|
+
const match = files.find((f) => f.startsWith(id) || f.replace(".json", "") === id);
|
|
120
|
+
if (match) {
|
|
121
|
+
fs.unlinkSync(path.join(CONV_DIR, match));
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function forkConversation(idOrConv: string | Conversation): Conversation | null {
|
|
128
|
+
const original =
|
|
129
|
+
typeof idOrConv === "string" ? loadConversation(idOrConv) : idOrConv;
|
|
130
|
+
if (!original) return null;
|
|
131
|
+
|
|
132
|
+
ensureDir();
|
|
133
|
+
const forked: Conversation = {
|
|
134
|
+
...original,
|
|
135
|
+
id: generateId(),
|
|
136
|
+
title: `Fork of: ${original.title}`,
|
|
137
|
+
createdAt: Date.now(),
|
|
138
|
+
updatedAt: Date.now(),
|
|
139
|
+
messages: original.messages.map((m) => ({ ...m })),
|
|
140
|
+
};
|
|
141
|
+
saveConversation(forked);
|
|
142
|
+
return forked;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function updateConversationTitle(id: string, title: string): void {
|
|
146
|
+
const conv = loadConversation(id);
|
|
147
|
+
if (!conv) return;
|
|
148
|
+
conv.title = title.length > 80 ? title.substring(0, 77) + "..." : title;
|
|
149
|
+
conv.updatedAt = Date.now();
|
|
150
|
+
saveConversation(conv);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatRelativeDate(timestamp: number): string {
|
|
154
|
+
const diff = Date.now() - timestamp;
|
|
155
|
+
const seconds = Math.floor(diff / 1000);
|
|
156
|
+
if (seconds < 60) return "just now";
|
|
157
|
+
const minutes = Math.floor(seconds / 60);
|
|
158
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
159
|
+
const hours = Math.floor(minutes / 60);
|
|
160
|
+
if (hours < 24) return `${hours}h ago`;
|
|
161
|
+
const days = Math.floor(hours / 24);
|
|
162
|
+
if (days < 30) return `${days}d ago`;
|
|
163
|
+
return new Date(timestamp).toLocaleDateString();
|
|
164
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Title — set/reset the terminal window title via escape sequences
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function setTerminalTitle(title: string): void {
|
|
6
|
+
if (process.stdout.isTTY) {
|
|
7
|
+
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resetTerminalTitle(): void {
|
|
12
|
+
if (process.stdout.isTTY) {
|
|
13
|
+
process.stdout.write(`\x1b]0;\x07`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Route — landing screen with logo, project info, shortcuts, and quick actions
|
|
3
|
+
*
|
|
4
|
+
* Responsive: adapts layout based on terminal dimensions.
|
|
5
|
+
* - Small terminals (< 60 cols or < 20 rows): compact layout, no logo
|
|
6
|
+
* - Medium terminals: smaller logo, condensed info
|
|
7
|
+
* - Large terminals: full figlet logo, spacious layout
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TextAttributes } from "@opentui/core";
|
|
11
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
12
|
+
import { useTheme } from "../context/theme";
|
|
13
|
+
import { InputArea } from "../components/input-area";
|
|
14
|
+
import type { ImageAttachment } from "@cdoing/ai";
|
|
15
|
+
|
|
16
|
+
// Full figlet logo (ANSI Shadow)
|
|
17
|
+
const LOGO_FULL = [
|
|
18
|
+
" ██████╗██████╗ ██████╗ ██╗███╗ ██╗ ██████╗ ",
|
|
19
|
+
"██╔════╝██╔══██╗██╔═══██╗██║████╗ ██║██╔════╝ ",
|
|
20
|
+
"██║ ██║ ██║██║ ██║██║██╔██╗ ██║██║ ███╗",
|
|
21
|
+
"██║ ██║ ██║██║ ██║██║██║╚██╗██║██║ ██║",
|
|
22
|
+
"╚██████╗██████╔╝╚██████╔╝██║██║ ╚████║╚██████╔╝",
|
|
23
|
+
" ╚═════╝╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Compact logo for smaller terminals
|
|
27
|
+
const LOGO_COMPACT = [
|
|
28
|
+
"┌─┐┌┐ ┌─┐┬┌┐┌┌─┐",
|
|
29
|
+
"│ │││ ││││││││ ┬",
|
|
30
|
+
"└─┘└┘└─┘└─┘┘└┘└─┘",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function Home(props: {
|
|
34
|
+
provider: string;
|
|
35
|
+
model: string;
|
|
36
|
+
workingDir: string;
|
|
37
|
+
themeId: string;
|
|
38
|
+
onAction?: (action: string) => void;
|
|
39
|
+
onSubmit?: (text: string, images?: ImageAttachment[]) => void;
|
|
40
|
+
}) {
|
|
41
|
+
const { theme } = useTheme();
|
|
42
|
+
const t = theme;
|
|
43
|
+
const dims = useTerminalDimensions();
|
|
44
|
+
const w = dims.width || 80;
|
|
45
|
+
const h = dims.height || 24;
|
|
46
|
+
|
|
47
|
+
const shortDir = () => {
|
|
48
|
+
const home = process.env.HOME || "";
|
|
49
|
+
const d = props.workingDir;
|
|
50
|
+
return home && d.startsWith(home) ? "~" + d.slice(home.length) : d;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const subtitle = "Multi-provider AI coding assistant";
|
|
54
|
+
const version = "v0.1.1";
|
|
55
|
+
|
|
56
|
+
// Determine layout size
|
|
57
|
+
const isSmall = w < 60 || h < 20;
|
|
58
|
+
const isMedium = !isSmall && (w < 80 || h < 30);
|
|
59
|
+
|
|
60
|
+
// Pick logo
|
|
61
|
+
const logo = isSmall ? null : isMedium ? LOGO_COMPACT : LOGO_FULL;
|
|
62
|
+
|
|
63
|
+
// Build info lines
|
|
64
|
+
const infoLines = [
|
|
65
|
+
["Provider", props.provider],
|
|
66
|
+
["Model", props.model],
|
|
67
|
+
["Theme", props.themeId],
|
|
68
|
+
["Directory", shortDir()],
|
|
69
|
+
];
|
|
70
|
+
const maxKeyLen = Math.max(...infoLines.map(([k]) => k.length));
|
|
71
|
+
|
|
72
|
+
// Quick actions
|
|
73
|
+
const actions = [
|
|
74
|
+
{ key: "Enter", label: "Send message / start session", id: "start" },
|
|
75
|
+
{ key: "Ctrl+P", label: "Switch model", id: "model" },
|
|
76
|
+
{ key: "Ctrl+T", label: "Change theme", id: "theme" },
|
|
77
|
+
{ key: "Ctrl+N", label: "New session", id: "new" },
|
|
78
|
+
{ key: "Ctrl+S", label: "Browse sessions", id: "sessions" },
|
|
79
|
+
{ key: "Ctrl+X", label: "Command palette", id: "commands" },
|
|
80
|
+
{ key: "/setup", label: "Setup wizard", id: "setup" },
|
|
81
|
+
{ key: "Ctrl+C", label: "Quit", id: "quit" },
|
|
82
|
+
];
|
|
83
|
+
const maxActionKey = Math.max(...actions.map((a) => a.key.length));
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
87
|
+
<box flexDirection="column" alignItems="center" justifyContent="center" flexGrow={1}>
|
|
88
|
+
{/* Logo */}
|
|
89
|
+
{logo && logo.map((line, i) => (
|
|
90
|
+
<text key={`logo-${i}`} fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
91
|
+
{line}
|
|
92
|
+
</text>
|
|
93
|
+
))}
|
|
94
|
+
|
|
95
|
+
{/* Subtitle + version */}
|
|
96
|
+
<text fg={t.textDim}>
|
|
97
|
+
{subtitle}
|
|
98
|
+
</text>
|
|
99
|
+
<text fg={t.textMuted}>
|
|
100
|
+
{version}
|
|
101
|
+
</text>
|
|
102
|
+
|
|
103
|
+
<text>{""}</text>
|
|
104
|
+
|
|
105
|
+
{/* Info section */}
|
|
106
|
+
{infoLines.map(([key, val], i) => {
|
|
107
|
+
const line = `${key.padStart(maxKeyLen)} ${val}`;
|
|
108
|
+
return (
|
|
109
|
+
<text key={`info-${i}`} fg={t.textDim}>
|
|
110
|
+
{line}
|
|
111
|
+
</text>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
|
|
115
|
+
<text>{""}</text>
|
|
116
|
+
|
|
117
|
+
{/* Actions */}
|
|
118
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
119
|
+
{"Actions"}
|
|
120
|
+
</text>
|
|
121
|
+
{actions.map((action, i) => {
|
|
122
|
+
const line = `${action.key.padStart(maxActionKey)} ${action.label}`;
|
|
123
|
+
return (
|
|
124
|
+
<text key={`act-${i}`} fg={t.textMuted}>
|
|
125
|
+
{line}
|
|
126
|
+
</text>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
|
|
130
|
+
<text>{""}</text>
|
|
131
|
+
|
|
132
|
+
{/* Footer */}
|
|
133
|
+
<text fg={t.textDim}>
|
|
134
|
+
{"Powered by @opentui/react + @cdoing/ai"}
|
|
135
|
+
</text>
|
|
136
|
+
</box>
|
|
137
|
+
|
|
138
|
+
{/* Input bar at bottom — user can start typing immediately */}
|
|
139
|
+
{props.onSubmit && (
|
|
140
|
+
<InputArea
|
|
141
|
+
onSubmit={props.onSubmit}
|
|
142
|
+
workingDir={props.workingDir}
|
|
143
|
+
placeholder="Start typing to begin a session... (/ commands, @ context)"
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</box>
|
|
147
|
+
);
|
|
148
|
+
}
|