@devang0907/agent-dev 0.1.1 → 0.1.3

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.
@@ -1,8 +1,31 @@
1
+ import { normalizeToolCalls } from "../providers/openai-compat.js";
1
2
  import { streamChat } from "../providers/registry.js";
2
3
  import { getToolDefinitions, executeTool } from "./tools/index.js";
4
+ const MAX_TOOL_ROUNDS = 6;
5
+ const MAX_SAME_TOOL_CALLS = 2;
3
6
  const DEFAULT_SYSTEM_PROMPT = `You are a helpful coding assistant with access to tools: read, write, edit, and bash.
4
- Use tools to inspect and modify the codebase. Be concise and accurate.
7
+ When the user asks you to create or modify files, call write or edit once with the full file content, then reply briefly to confirm.
8
+ Do NOT call the same tool repeatedly with the same arguments. One successful write is enough.
5
9
  Working directory: ${process.cwd()}`;
10
+ function isToolUseFailedError(message) {
11
+ return /Failed to call a function|tool_use_failed|failed_generation/i.test(message);
12
+ }
13
+ function hadSuccessfulToolResults(context) {
14
+ return context.some((m) => m.role === "tool" && m.content.length > 0 && !m.content.startsWith("Error:"));
15
+ }
16
+ function toolSignature(name, args) {
17
+ return `${name}:${JSON.stringify(args)}`;
18
+ }
19
+ function dedupeToolCalls(toolCalls) {
20
+ const seen = new Set();
21
+ return toolCalls.filter((tc) => {
22
+ const key = `${tc.name}:${tc.arguments}`;
23
+ if (seen.has(key))
24
+ return false;
25
+ seen.add(key);
26
+ return true;
27
+ });
28
+ }
6
29
  async function collectStream(model, messages, settings, systemPrompt, signal, onEvent) {
7
30
  const tools = getToolDefinitions();
8
31
  let content = "";
@@ -13,7 +36,7 @@ async function collectStream(model, messages, settings, systemPrompt, signal, on
13
36
  systemPrompt,
14
37
  thinkingLevel: settings.thinkingLevel,
15
38
  signal,
16
- });
39
+ }, settings);
17
40
  for await (const event of stream) {
18
41
  if (event.type === "text_delta") {
19
42
  content += event.delta;
@@ -35,32 +58,53 @@ async function collectStream(model, messages, settings, systemPrompt, signal, on
35
58
  return { content, toolCalls: [], error: event.message };
36
59
  }
37
60
  }
38
- const toolCalls = Array.from(toolCallMap.values()).filter((tc) => tc.name);
61
+ const toolCalls = normalizeToolCalls(Array.from(toolCallMap.values()).filter((tc) => tc.name));
39
62
  return { content, toolCalls };
40
63
  }
64
+ function finishGracefully(context, content, onEvent) {
65
+ const msg = content.trim() || "Done — changes saved successfully.";
66
+ if (!content.trim()) {
67
+ onEvent({ type: "text_delta", delta: msg });
68
+ }
69
+ context.push({ role: "assistant", content: msg });
70
+ onEvent({ type: "turn_end" });
71
+ }
41
72
  export async function runAgentLoop(options) {
42
73
  const { model, messages, settings, workdir, systemPrompt = DEFAULT_SYSTEM_PROMPT, signal, onEvent, } = options;
43
74
  const context = [...messages];
75
+ const callCounts = new Map();
76
+ let toolRound = 0;
44
77
  while (true) {
45
78
  if (signal?.aborted)
46
79
  break;
80
+ toolRound++;
81
+ if (toolRound > MAX_TOOL_ROUNDS) {
82
+ finishGracefully(context, "Done — stopped after too many tool calls.", onEvent);
83
+ break;
84
+ }
47
85
  onEvent({ type: "message_start", role: "assistant" });
48
86
  const { content, toolCalls, error } = await collectStream(model, context, settings, systemPrompt, signal, onEvent);
49
87
  if (error) {
88
+ if (isToolUseFailedError(error) && hadSuccessfulToolResults(context)) {
89
+ finishGracefully(context, content, onEvent);
90
+ break;
91
+ }
50
92
  onEvent({ type: "error", message: error });
51
93
  break;
52
94
  }
95
+ const uniqueCalls = dedupeToolCalls(toolCalls);
53
96
  const assistantMsg = {
54
97
  role: "assistant",
55
98
  content,
56
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
99
+ toolCalls: uniqueCalls.length > 0 ? uniqueCalls : undefined,
57
100
  };
58
101
  context.push(assistantMsg);
59
- if (toolCalls.length === 0) {
102
+ if (uniqueCalls.length === 0) {
60
103
  onEvent({ type: "turn_end" });
61
104
  break;
62
105
  }
63
- for (const tc of toolCalls) {
106
+ let stopAfterBatch = false;
107
+ for (const tc of uniqueCalls) {
64
108
  onEvent({ type: "tool_call", toolCall: tc });
65
109
  let args = {};
66
110
  try {
@@ -69,6 +113,16 @@ export async function runAgentLoop(options) {
69
113
  catch {
70
114
  args = {};
71
115
  }
116
+ const sig = toolSignature(tc.name, args);
117
+ const prev = callCounts.get(sig) ?? 0;
118
+ callCounts.set(sig, prev + 1);
119
+ if (prev >= MAX_SAME_TOOL_CALLS) {
120
+ const skip = "Skipped — already executed this action.";
121
+ onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result: skip });
122
+ context.push({ role: "tool", content: skip, toolCallId: tc.id, name: tc.name });
123
+ stopAfterBatch = true;
124
+ continue;
125
+ }
72
126
  const result = await executeTool(tc.name, args, workdir);
73
127
  onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result });
74
128
  context.push({
@@ -77,6 +131,13 @@ export async function runAgentLoop(options) {
77
131
  toolCallId: tc.id,
78
132
  name: tc.name,
79
133
  });
134
+ if (!result.startsWith("Error:") && (tc.name === "write" || tc.name === "edit")) {
135
+ stopAfterBatch = true;
136
+ }
137
+ }
138
+ if (stopAfterBatch) {
139
+ finishGracefully(context, content, onEvent);
140
+ break;
80
141
  }
81
142
  }
82
143
  return context.slice(messages.length);
@@ -20,6 +20,7 @@ export const readTool = {
20
20
  path: { type: "string", description: "File path relative to project root" },
21
21
  },
22
22
  required: ["path"],
23
+ additionalProperties: false,
23
24
  },
24
25
  };
25
26
  export async function executeRead(args, workdir = DEFAULT_WORKDIR) {
@@ -41,6 +42,7 @@ export const writeTool = {
41
42
  content: { type: "string", description: "Content to write" },
42
43
  },
43
44
  required: ["path", "content"],
45
+ additionalProperties: false,
44
46
  },
45
47
  };
46
48
  export async function executeWrite(args, workdir = DEFAULT_WORKDIR) {
@@ -60,6 +62,7 @@ export const editTool = {
60
62
  new_string: { type: "string", description: "Replacement string" },
61
63
  },
62
64
  required: ["path", "old_string", "new_string"],
65
+ additionalProperties: false,
63
66
  },
64
67
  };
65
68
  export async function executeEdit(args, workdir = DEFAULT_WORKDIR) {
@@ -85,6 +88,7 @@ export const bashTool = {
85
88
  command: { type: "string", description: "Shell command to execute" },
86
89
  },
87
90
  required: ["command"],
91
+ additionalProperties: false,
88
92
  },
89
93
  };
90
94
  export async function executeBash(args, workdir = DEFAULT_WORKDIR) {
@@ -1,4 +1,5 @@
1
1
  import OpenAI from "openai";
2
+ import { toOpenAIMessages, toOpenAITools, processOpenAIStream, formatApiError, } from "./openai-compat.js";
2
3
  export const PROVIDER_ID = "groq";
3
4
  export const DEFAULT_MODEL = "llama-3.3-70b-versatile";
4
5
  export const API_KEY_ENV = "GROQ_API_KEY";
@@ -13,51 +14,6 @@ export function getApiKey(settings) {
13
14
  export function hasAuth(settings) {
14
15
  return !!getApiKey(settings);
15
16
  }
16
- function toOpenAIMessages(ctx) {
17
- const msgs = [];
18
- if (ctx.systemPrompt) {
19
- msgs.push({ role: "system", content: ctx.systemPrompt });
20
- }
21
- for (const m of ctx.messages) {
22
- if (m.role === "user") {
23
- msgs.push({ role: "user", content: m.content });
24
- }
25
- else if (m.role === "assistant") {
26
- if (m.toolCalls?.length) {
27
- msgs.push({
28
- role: "assistant",
29
- content: m.content || null,
30
- tool_calls: m.toolCalls.map((tc) => ({
31
- id: tc.id,
32
- type: "function",
33
- function: { name: tc.name, arguments: tc.arguments },
34
- })),
35
- });
36
- }
37
- else {
38
- msgs.push({ role: "assistant", content: m.content });
39
- }
40
- }
41
- else if (m.role === "tool") {
42
- msgs.push({
43
- role: "tool",
44
- tool_call_id: m.toolCallId,
45
- content: m.content,
46
- });
47
- }
48
- }
49
- return msgs;
50
- }
51
- function toOpenAITools(tools) {
52
- return tools.map((t) => ({
53
- type: "function",
54
- function: {
55
- name: t.name,
56
- description: t.description,
57
- parameters: t.parameters,
58
- },
59
- }));
60
- }
61
17
  export async function* streamChat(model, ctx, settings) {
62
18
  const apiKey = getApiKey(settings);
63
19
  if (!apiKey) {
@@ -65,49 +21,19 @@ export async function* streamChat(model, ctx, settings) {
65
21
  return;
66
22
  }
67
23
  const client = new OpenAI({ apiKey, baseURL: BASE_URL });
24
+ const hasTools = ctx.tools.length > 0;
68
25
  try {
69
26
  const stream = await client.chat.completions.create({
70
27
  model: model.id,
71
28
  messages: toOpenAIMessages(ctx),
72
- tools: ctx.tools.length > 0 ? toOpenAITools(ctx.tools) : undefined,
29
+ tools: hasTools ? toOpenAITools(ctx.tools) : undefined,
30
+ tool_choice: hasTools ? "auto" : undefined,
31
+ parallel_tool_calls: false,
73
32
  stream: true,
74
33
  }, { signal: ctx.signal });
75
- const toolCalls = new Map();
76
- for await (const chunk of stream) {
77
- const delta = chunk.choices[0]?.delta;
78
- if (!delta)
79
- continue;
80
- if (delta.content) {
81
- yield { type: "text_delta", delta: delta.content };
82
- }
83
- if (delta.tool_calls) {
84
- for (const tc of delta.tool_calls) {
85
- const idx = tc.index;
86
- if (!toolCalls.has(idx)) {
87
- toolCalls.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", arguments: "" });
88
- }
89
- const existing = toolCalls.get(idx);
90
- if (tc.id)
91
- existing.id = tc.id;
92
- if (tc.function?.name)
93
- existing.name = tc.function.name;
94
- if (tc.function?.arguments) {
95
- existing.arguments += tc.function.arguments;
96
- yield {
97
- type: "tool_call_delta",
98
- index: idx,
99
- id: existing.id,
100
- name: existing.name,
101
- argumentsDelta: tc.function.arguments,
102
- };
103
- }
104
- }
105
- }
106
- }
107
- yield { type: "done" };
34
+ yield* processOpenAIStream(stream);
108
35
  }
109
36
  catch (err) {
110
- const message = err instanceof Error ? err.message : String(err);
111
- yield { type: "error", message };
37
+ yield { type: "error", message: formatApiError(err) };
112
38
  }
113
39
  }
@@ -0,0 +1,8 @@
1
+ import type OpenAI from "openai";
2
+ import type { ChatContext, StreamEvent, ToolCall } from "./types.js";
3
+ export declare function sanitizeToolParameters(params: Record<string, unknown>): Record<string, unknown>;
4
+ export declare function toOpenAITools(tools: ChatContext["tools"]): OpenAI.Chat.ChatCompletionTool[];
5
+ export declare function normalizeToolCalls(toolCalls: ToolCall[]): ToolCall[];
6
+ export declare function toOpenAIMessages(ctx: ChatContext): OpenAI.Chat.ChatCompletionMessageParam[];
7
+ export declare function processOpenAIStream(stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>): AsyncGenerator<StreamEvent>;
8
+ export declare function formatApiError(err: unknown): string;
@@ -0,0 +1,117 @@
1
+ export function sanitizeToolParameters(params) {
2
+ return {
3
+ ...params,
4
+ type: "object",
5
+ additionalProperties: false,
6
+ };
7
+ }
8
+ export function toOpenAITools(tools) {
9
+ return tools.map((t) => ({
10
+ type: "function",
11
+ function: {
12
+ name: t.name,
13
+ description: t.description,
14
+ parameters: sanitizeToolParameters(t.parameters),
15
+ },
16
+ }));
17
+ }
18
+ export function normalizeToolCalls(toolCalls) {
19
+ return toolCalls.map((tc, i) => ({
20
+ id: tc.id?.trim() || `call_${Date.now()}_${i}`,
21
+ name: tc.name,
22
+ arguments: tc.arguments?.trim() || "{}",
23
+ }));
24
+ }
25
+ export function toOpenAIMessages(ctx) {
26
+ const msgs = [];
27
+ if (ctx.systemPrompt) {
28
+ msgs.push({ role: "system", content: ctx.systemPrompt });
29
+ }
30
+ for (const m of ctx.messages) {
31
+ if (m.role === "user") {
32
+ msgs.push({ role: "user", content: m.content });
33
+ }
34
+ else if (m.role === "assistant") {
35
+ if (m.toolCalls?.length) {
36
+ const toolCalls = normalizeToolCalls(m.toolCalls);
37
+ msgs.push({
38
+ role: "assistant",
39
+ content: m.content || null,
40
+ tool_calls: toolCalls.map((tc) => ({
41
+ id: tc.id,
42
+ type: "function",
43
+ function: { name: tc.name, arguments: tc.arguments },
44
+ })),
45
+ });
46
+ }
47
+ else {
48
+ msgs.push({ role: "assistant", content: m.content });
49
+ }
50
+ }
51
+ else if (m.role === "tool") {
52
+ msgs.push({
53
+ role: "tool",
54
+ tool_call_id: m.toolCallId,
55
+ content: m.content,
56
+ ...(m.name ? { name: m.name } : {}),
57
+ });
58
+ }
59
+ }
60
+ return msgs;
61
+ }
62
+ export async function* processOpenAIStream(stream) {
63
+ const toolCalls = new Map();
64
+ for await (const chunk of stream) {
65
+ const choice = chunk.choices[0];
66
+ if (choice?.finish_reason === "tool_calls" || choice?.delta?.tool_calls) {
67
+ // normal path
68
+ }
69
+ const delta = choice?.delta;
70
+ if (!delta)
71
+ continue;
72
+ if (delta.content) {
73
+ yield { type: "text_delta", delta: delta.content };
74
+ }
75
+ if (delta.tool_calls) {
76
+ for (const tc of delta.tool_calls) {
77
+ const idx = tc.index ?? 0;
78
+ if (!toolCalls.has(idx)) {
79
+ toolCalls.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", arguments: "" });
80
+ }
81
+ const existing = toolCalls.get(idx);
82
+ if (tc.id)
83
+ existing.id = tc.id;
84
+ if (tc.function?.name)
85
+ existing.name = tc.function.name;
86
+ if (tc.function?.arguments) {
87
+ existing.arguments += tc.function.arguments;
88
+ yield {
89
+ type: "tool_call_delta",
90
+ index: idx,
91
+ id: existing.id,
92
+ name: existing.name,
93
+ argumentsDelta: tc.function.arguments,
94
+ };
95
+ }
96
+ }
97
+ }
98
+ if (chunk.usage) {
99
+ yield {
100
+ type: "done",
101
+ usage: {
102
+ inputTokens: chunk.usage.prompt_tokens,
103
+ outputTokens: chunk.usage.completion_tokens,
104
+ },
105
+ };
106
+ }
107
+ }
108
+ yield { type: "done" };
109
+ }
110
+ export function formatApiError(err) {
111
+ const apiErr = err;
112
+ const failed = apiErr.error?.failed_generation;
113
+ const msg = apiErr.error?.message ?? apiErr.message ?? "API error";
114
+ if (failed)
115
+ return `${msg}\nModel output: ${failed.slice(0, 300)}`;
116
+ return msg;
117
+ }
@@ -1,4 +1,5 @@
1
1
  import OpenAI from "openai";
2
+ import { toOpenAIMessages, toOpenAITools, processOpenAIStream, formatApiError, } from "./openai-compat.js";
2
3
  export const PROVIDER_ID = "free";
3
4
  export const DEFAULT_MODEL = "meta-llama/llama-3.3-70b-instruct:free";
4
5
  export const API_KEY_ENV = "OPENROUTER_API_KEY";
@@ -26,51 +27,6 @@ export function getApiKey(settings) {
26
27
  export function hasAuth(settings) {
27
28
  return !!getApiKey(settings);
28
29
  }
29
- function toOpenAIMessages(ctx) {
30
- const msgs = [];
31
- if (ctx.systemPrompt) {
32
- msgs.push({ role: "system", content: ctx.systemPrompt });
33
- }
34
- for (const m of ctx.messages) {
35
- if (m.role === "user") {
36
- msgs.push({ role: "user", content: m.content });
37
- }
38
- else if (m.role === "assistant") {
39
- if (m.toolCalls?.length) {
40
- msgs.push({
41
- role: "assistant",
42
- content: m.content || null,
43
- tool_calls: m.toolCalls.map((tc) => ({
44
- id: tc.id,
45
- type: "function",
46
- function: { name: tc.name, arguments: tc.arguments },
47
- })),
48
- });
49
- }
50
- else {
51
- msgs.push({ role: "assistant", content: m.content });
52
- }
53
- }
54
- else if (m.role === "tool") {
55
- msgs.push({
56
- role: "tool",
57
- tool_call_id: m.toolCallId,
58
- content: m.content,
59
- });
60
- }
61
- }
62
- return msgs;
63
- }
64
- function toOpenAITools(tools) {
65
- return tools.map((t) => ({
66
- type: "function",
67
- function: {
68
- name: t.name,
69
- description: t.description,
70
- parameters: t.parameters,
71
- },
72
- }));
73
- }
74
30
  export async function* streamChat(model, ctx, settings) {
75
31
  const apiKey = getApiKey(settings);
76
32
  if (!apiKey) {
@@ -85,49 +41,19 @@ export async function* streamChat(model, ctx, settings) {
85
41
  "X-Title": "agent-dev",
86
42
  },
87
43
  });
44
+ const hasTools = ctx.tools.length > 0;
88
45
  try {
89
46
  const stream = await client.chat.completions.create({
90
47
  model: model.id,
91
48
  messages: toOpenAIMessages(ctx),
92
- tools: ctx.tools.length > 0 ? toOpenAITools(ctx.tools) : undefined,
49
+ tools: hasTools ? toOpenAITools(ctx.tools) : undefined,
50
+ tool_choice: hasTools ? "auto" : undefined,
51
+ parallel_tool_calls: false,
93
52
  stream: true,
94
53
  }, { signal: ctx.signal });
95
- const toolCalls = new Map();
96
- for await (const chunk of stream) {
97
- const delta = chunk.choices[0]?.delta;
98
- if (!delta)
99
- continue;
100
- if (delta.content) {
101
- yield { type: "text_delta", delta: delta.content };
102
- }
103
- if (delta.tool_calls) {
104
- for (const tc of delta.tool_calls) {
105
- const idx = tc.index;
106
- if (!toolCalls.has(idx)) {
107
- toolCalls.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", arguments: "" });
108
- }
109
- const existing = toolCalls.get(idx);
110
- if (tc.id)
111
- existing.id = tc.id;
112
- if (tc.function?.name)
113
- existing.name = tc.function.name;
114
- if (tc.function?.arguments) {
115
- existing.arguments += tc.function.arguments;
116
- yield {
117
- type: "tool_call_delta",
118
- index: idx,
119
- id: existing.id,
120
- name: existing.name,
121
- argumentsDelta: tc.function.arguments,
122
- };
123
- }
124
- }
125
- }
126
- }
127
- yield { type: "done" };
54
+ yield* processOpenAIStream(stream);
128
55
  }
129
56
  catch (err) {
130
- const message = err instanceof Error ? err.message : String(err);
131
- yield { type: "error", message };
57
+ yield { type: "error", message: formatApiError(err) };
132
58
  }
133
59
  }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import type { ThemeColors } from "./theme.js";
3
+ import type { Model, ProviderId } from "../providers/types.js";
4
+ interface ApiKeyPromptProps {
5
+ theme: ThemeColors;
6
+ provider: ProviderId;
7
+ model: Model;
8
+ onSubmit: (apiKey: string) => void;
9
+ onCancel: () => void;
10
+ }
11
+ export declare function ApiKeyPrompt({ theme, provider, model, onSubmit, onCancel }: ApiKeyPromptProps): React.JSX.Element;
12
+ export {};
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { PROVIDER_LABELS } from "../config/models.js";
5
+ import { PROVIDER_ENV_VARS } from "../providers/registry.js";
6
+ import { LeftBorder } from "./LeftBorder.js";
7
+ function BlinkingCursor({ theme, visible }) {
8
+ if (!visible)
9
+ return null;
10
+ return _jsx(Text, { color: theme.primary, children: "\u258C" });
11
+ }
12
+ export function ApiKeyPrompt({ theme, provider, model, onSubmit, onCancel }) {
13
+ const [value, setValue] = useState("");
14
+ const [cursorOn, setCursorOn] = useState(true);
15
+ useEffect(() => {
16
+ const id = setInterval(() => setCursorOn((v) => !v), 530);
17
+ return () => clearInterval(id);
18
+ }, []);
19
+ useInput((input, key) => {
20
+ if (key.escape) {
21
+ onCancel();
22
+ return;
23
+ }
24
+ if (key.return) {
25
+ const trimmed = value.trim();
26
+ if (trimmed)
27
+ onSubmit(trimmed);
28
+ return;
29
+ }
30
+ if (key.backspace || key.delete) {
31
+ setValue((v) => v.slice(0, -1));
32
+ return;
33
+ }
34
+ if (input && !key.ctrl && !key.meta) {
35
+ setValue((v) => v + input);
36
+ }
37
+ }, { isActive: true });
38
+ const envVars = PROVIDER_ENV_VARS[provider];
39
+ const masked = "•".repeat(value.length);
40
+ return (_jsx(Box, { flexDirection: "column", marginX: 2, marginTop: 1, marginBottom: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.primary, children: [_jsx(Text, { color: theme.text, bold: true, children: "API key required" }), _jsx(Text, { color: theme.textMuted, children: " Enter save \u00B7 Esc cancel" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.textMuted, children: ["Provider: ", PROVIDER_LABELS[provider]] }), _jsxs(Text, { color: theme.textMuted, children: ["Model: ", model.name] })] }), _jsx(Box, { flexDirection: "row", marginTop: 1, borderStyle: "round", borderColor: theme.primary, paddingX: 1, paddingY: 0, children: value.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text, children: masked }), _jsx(BlinkingCursor, { theme: theme, visible: cursorOn })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.textMuted, children: "Paste API key here\u2026" }), _jsx(BlinkingCursor, { theme: theme, visible: cursorOn })] })) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.textMuted, children: ["Or set env: ", envVars.join(" · ")] }), _jsx(Text, { color: theme.textMuted, children: "Saved to ~/.agent-dev/settings.json" })] })] }) }));
41
+ }
package/dist/ui/App.js CHANGED
@@ -5,10 +5,18 @@ import { ChatView } from "./ChatView.js";
5
5
  import { Editor } from "./Editor.js";
6
6
  import { Footer } from "./Footer.js";
7
7
  import { ModelSelector } from "./ModelSelector.js";
8
+ import { ApiKeyPrompt } from "./ApiKeyPrompt.js";
8
9
  import { SettingsView } from "./SettingsView.js";
10
+ import { hasProviderAuth, getDefaultModelForProvider } from "../providers/registry.js";
11
+ import { findModel } from "../config/models.js";
9
12
  import { StartupBanner } from "./StartupBanner.js";
10
13
  import { getTheme } from "./theme.js";
11
- import { saveSettings } from "../config/settings.js";
14
+ function modelForProvider(provider, settings) {
15
+ const current = findModel(settings.defaultProvider, settings.defaultModel);
16
+ if (current?.provider === provider)
17
+ return current;
18
+ return getDefaultModelForProvider(provider);
19
+ }
12
20
  export function App({ session, workdir, onQuit }) {
13
21
  const { exit } = useApp();
14
22
  const [displayMessages, setDisplayMessages] = useState(() => session.getMessages().map((m) => ({
@@ -19,11 +27,44 @@ export function App({ session, workdir, onQuit }) {
19
27
  const [streamingText, setStreamingText] = useState("");
20
28
  const [overlay, setOverlay] = useState("none");
21
29
  const [modelFilter, setModelFilter] = useState();
30
+ const [pendingModel, setPendingModel] = useState(null);
31
+ const [apiKeyReturnOverlay, setApiKeyReturnOverlay] = useState("none");
22
32
  const [settings, setSettings] = useState(session.getSettings());
23
33
  const [model, setModel] = useState(session.getModel());
24
34
  const [running, setRunning] = useState(false);
25
35
  const streamingRef = useRef("");
36
+ const startupChecked = useRef(false);
26
37
  const theme = getTheme();
38
+ const openApiKeyPrompt = useCallback((target, returnTo = "none") => {
39
+ setPendingModel(target);
40
+ setApiKeyReturnOverlay(returnTo);
41
+ setOverlay("apiKey");
42
+ }, []);
43
+ const saveApiKey = useCallback((apiKey) => {
44
+ if (!pendingModel)
45
+ return;
46
+ const updated = {
47
+ ...settings,
48
+ apiKeys: { ...settings.apiKeys, [pendingModel.provider]: apiKey },
49
+ };
50
+ session.updateSettings(updated);
51
+ setSettings(updated);
52
+ session.setModel(pendingModel);
53
+ setModel(pendingModel);
54
+ setPendingModel(null);
55
+ setOverlay(apiKeyReturnOverlay);
56
+ setApiKeyReturnOverlay("none");
57
+ setModelFilter(undefined);
58
+ }, [pendingModel, settings, session, apiKeyReturnOverlay]);
59
+ useEffect(() => {
60
+ if (startupChecked.current)
61
+ return;
62
+ startupChecked.current = true;
63
+ const current = session.getModel();
64
+ if (!hasProviderAuth(current.provider, settings)) {
65
+ openApiKeyPrompt(current, "none");
66
+ }
67
+ }, [session, settings, openApiKeyPrompt]);
27
68
  useEffect(() => {
28
69
  const handler = (event) => {
29
70
  switch (event.type) {
@@ -72,6 +113,9 @@ export function App({ session, workdir, onQuit }) {
72
113
  streamingRef.current = "";
73
114
  setStreamingText("");
74
115
  setRunning(false);
116
+ if (/Missing .*API_KEY/i.test(event.message)) {
117
+ openApiKeyPrompt(model, "none");
118
+ }
75
119
  break;
76
120
  case "model_changed":
77
121
  setModel(event.model);
@@ -82,14 +126,14 @@ export function App({ session, workdir, onQuit }) {
82
126
  return () => {
83
127
  session.off("event", handler);
84
128
  };
85
- }, [session]);
129
+ }, [session, model, openApiKeyPrompt]);
86
130
  useInput((_, key) => {
87
131
  if (overlay !== "none")
88
132
  return;
89
133
  if (key.escape && running) {
90
134
  session.abort();
91
135
  }
92
- });
136
+ }, { isActive: overlay === "none" });
93
137
  const handleSubmit = useCallback(async (value) => {
94
138
  if (value === "/quit") {
95
139
  onQuit();
@@ -114,10 +158,18 @@ export function App({ session, workdir, onQuit }) {
114
158
  }
115
159
  if (running)
116
160
  return;
161
+ if (!hasProviderAuth(model.provider, settings)) {
162
+ openApiKeyPrompt(model, "none");
163
+ return;
164
+ }
117
165
  await session.prompt(value);
118
- }, [session, running, onQuit, exit]);
166
+ }, [session, running, onQuit, exit, model, settings, openApiKeyPrompt]);
119
167
  const hasChat = displayMessages.length > 0 || streamingText.length > 0;
120
168
  return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Box, { paddingX: 2, marginBottom: 1, children: _jsx(StartupBanner, { theme: theme, compact: hasChat }) }), _jsx(ChatView, { messages: displayMessages, theme: theme, model: model, streamingText: streamingText, running: running }), _jsx(Footer, { workdir: workdir, model: model, theme: theme }), overlay === "none" && (_jsx(Editor, { theme: theme, model: model, disabled: running, running: running, onSubmit: handleSubmit })), overlay === "model" && (_jsx(ModelSelector, { theme: theme, settings: settings, filter: modelFilter, onSelect: (m) => {
169
+ if (!hasProviderAuth(m.provider, settings)) {
170
+ openApiKeyPrompt(m, "model");
171
+ return;
172
+ }
121
173
  session.setModel(m);
122
174
  setModel(m);
123
175
  setOverlay("none");
@@ -125,9 +177,14 @@ export function App({ session, workdir, onQuit }) {
125
177
  }, onClose: () => {
126
178
  setOverlay("none");
127
179
  setModelFilter(undefined);
180
+ } })), overlay === "apiKey" && pendingModel && (_jsx(ApiKeyPrompt, { theme: theme, provider: pendingModel.provider, model: pendingModel, onSubmit: saveApiKey, onCancel: () => {
181
+ setPendingModel(null);
182
+ setOverlay(apiKeyReturnOverlay);
183
+ setApiKeyReturnOverlay("none");
128
184
  } })), overlay === "settings" && (_jsx(SettingsView, { theme: theme, settings: settings, onUpdate: (s) => {
129
185
  session.updateSettings(s);
130
186
  setSettings(s);
131
- saveSettings(s);
187
+ }, onSetApiKey: (provider) => {
188
+ openApiKeyPrompt(modelForProvider(provider, settings), "settings");
132
189
  }, onClose: () => setOverlay("none") }))] }));
133
190
  }
package/dist/ui/Editor.js CHANGED
@@ -71,7 +71,7 @@ export function Editor({ theme, model, disabled, running, onSubmit }) {
71
71
  setValue(newVal);
72
72
  updateSuggestions(newVal);
73
73
  }
74
- });
74
+ }, { isActive: !disabled });
75
75
  const placeholder = "Ask anything…";
76
76
  const showCursor = !disabled && cursorOn;
77
77
  return (_jsxs(Box, { flexDirection: "column", marginX: 2, children: [suggestions.length > 0 && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginBottom: 1, children: suggestions.map((s) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: s.cmd }), _jsxs(Text, { color: theme.textMuted, children: [" \u2014 ", s.desc] })] }, s.cmd))) })), _jsxs(Panel, { theme: theme, borderColor: disabled ? theme.border : theme.primary, marginBottom: 0, children: [_jsx(Box, { flexDirection: "row", children: value.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text, children: value }), _jsx(BlinkingCursor, { theme: theme, visible: showCursor })] })) : showCursor ? (_jsx(BlinkingCursor, { theme: theme, visible: true })) : (_jsx(Text, { color: theme.textMuted, children: placeholder })) }), _jsxs(Text, { color: theme.textMuted, children: ["agent-dev \u00B7 ", _jsx(Text, { color: theme.text, children: modelRef(model) })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: running ? (_jsxs(Text, { color: theme.textMuted, children: [_jsx(Text, { color: theme.primary, children: SPINNER_FRAMES[spinIdx] }), " ", "esc interrupt"] })) : (_jsx(Text, { color: theme.textMuted, children: "Tab completes /commands" })) })] }));
@@ -30,15 +30,14 @@ export function ModelSelector({ theme, settings, filter, onSelect, onClose }) {
30
30
  if (key.return && filtered[safeIndex]) {
31
31
  onSelect(filtered[safeIndex]);
32
32
  }
33
- });
33
+ }, { isActive: true });
34
34
  const providers = ["openai", "groq", "gemini", "free"];
35
35
  let lastProvider;
36
- return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.borderActive, children: [_jsx(Text, { color: theme.text, bold: true, children: "/model" }), _jsx(Text, { color: theme.textMuted, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close" }), filter && _jsxs(Text, { color: theme.textMuted, children: [" filter: ", filter] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [filtered.length === 0 && _jsx(Text, { color: theme.textMuted, children: "No models match" }), filtered.map((m, i) => {
37
- const hasKey = hasProviderAuth(m.provider, settings);
36
+ return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.borderActive, children: [_jsx(Text, { color: theme.text, bold: true, children: "/model" }), _jsx(Text, { color: theme.textMuted, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close" }), _jsx(Text, { color: theme.textMuted, children: " Models without a key will prompt for an API key" }), filter && _jsxs(Text, { color: theme.textMuted, children: [" filter: ", filter] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [filtered.length === 0 && _jsx(Text, { color: theme.textMuted, children: "No models match" }), filtered.map((m, i) => {
38
37
  const selected = i === safeIndex;
39
38
  const showHeader = m.provider !== lastProvider;
40
39
  lastProvider = m.provider;
41
- return (_jsxs(Box, { flexDirection: "column", children: [showHeader && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: PROVIDER_LABELS[m.provider] }) })), _jsxs(Text, { color: selected ? theme.primary : theme.text, children: [selected ? "› " : " ", m.name, !hasKey && _jsx(Text, { color: theme.warning, children: " (no key)" }), selected && _jsxs(Text, { color: theme.textMuted, children: [" ", modelRef(m)] })] })] }, modelRef(m)));
40
+ return (_jsxs(Box, { flexDirection: "column", children: [showHeader && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: PROVIDER_LABELS[m.provider] }) })), _jsxs(Text, { color: selected ? theme.primary : theme.text, children: [selected ? "› " : " ", m.name, selected && _jsxs(Text, { color: theme.textMuted, children: [" ", modelRef(m)] })] })] }, modelRef(m)));
42
41
  })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: providers.map((p) => {
43
42
  const ok = hasProviderAuth(p, settings);
44
43
  return `${ok ? "●" : "○"} ${p}`;
@@ -1,11 +1,13 @@
1
1
  import React from "react";
2
2
  import type { ThemeColors } from "./theme.js";
3
3
  import type { Settings } from "../config/settings.js";
4
+ import type { ProviderId } from "../providers/types.js";
4
5
  interface SettingsViewProps {
5
6
  theme: ThemeColors;
6
7
  settings: Settings;
7
8
  onUpdate: (settings: Settings) => void;
9
+ onSetApiKey: (provider: ProviderId) => void;
8
10
  onClose: () => void;
9
11
  }
10
- export declare function SettingsView({ theme, settings, onUpdate, onClose }: SettingsViewProps): React.JSX.Element;
12
+ export declare function SettingsView({ theme, settings, onUpdate, onSetApiKey, onClose }: SettingsViewProps): React.JSX.Element;
11
13
  export {};
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
- import { PROVIDER_ENV_VARS } from "../providers/registry.js";
4
+ import { PROVIDER_ENV_VARS, hasProviderAuth } from "../providers/registry.js";
5
5
  import { LeftBorder } from "./LeftBorder.js";
6
6
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
7
- export function SettingsView({ theme, settings, onUpdate, onClose }) {
7
+ const PROVIDERS = ["openai", "groq", "gemini", "free"];
8
+ export function SettingsView({ theme, settings, onUpdate, onSetApiKey, onClose }) {
9
+ const items = ["thinkingLevel", ...PROVIDERS];
8
10
  const [index, setIndex] = useState(0);
9
- const items = ["thinkingLevel", "envKeys"];
10
11
  useInput((_, key) => {
11
12
  if (key.escape)
12
13
  onClose();
@@ -21,12 +22,17 @@ export function SettingsView({ theme, settings, onUpdate, onClose }) {
21
22
  const next = THINKING_LEVELS[(cur + 1) % THINKING_LEVELS.length];
22
23
  onUpdate({ ...settings, thinkingLevel: next });
23
24
  }
25
+ else {
26
+ onSetApiKey(item);
27
+ }
24
28
  }
25
- });
26
- const providers = ["openai", "groq", "gemini", "free"];
27
- return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.borderActive, children: [_jsx(Text, { color: theme.text, bold: true, children: "/settings" }), _jsx(Text, { color: theme.textMuted, children: " Enter cycle \u00B7 Esc close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: index === 0 ? theme.primary : theme.text, children: [index === 0 ? "› " : " ", "Thinking:", " ", _jsx(Text, { bold: true, children: settings.thinkingLevel })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: index === 1 ? theme.primary : theme.textMuted, children: [index === 1 ? "› " : " ", "API keys"] }) }), providers.map((p) => {
29
+ }, { isActive: true });
30
+ return (_jsx(Box, { paddingX: 2, marginTop: 1, marginBottom: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.borderActive, children: [_jsx(Text, { color: theme.text, bold: true, children: "/settings" }), _jsx(Text, { color: theme.textMuted, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: index === 0 ? theme.primary : theme.text, children: [index === 0 ? "" : " ", "Thinking:", " ", _jsx(Text, { bold: true, children: settings.thinkingLevel }), index === 0 && _jsx(Text, { color: theme.textMuted, children: " (Enter to cycle)" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: "API keys \u2014 Enter to set / update" }) }), PROVIDERS.map((p, i) => {
31
+ const idx = i + 1;
28
32
  const vars = PROVIDER_ENV_VARS[p];
29
- const set = vars.some((v) => process.env[v]);
30
- return (_jsxs(Text, { color: set ? theme.success : theme.textMuted, children: [" ", set ? "✓" : "○", " ", p, ": ", vars.join(" · ")] }, p));
31
- })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: "OPENAI_API_KEY \u00B7 GROQ_API_KEY \u00B7 GEMINI_API_KEY \u00B7 OPENROUTER_API_KEY" }) })] }) }));
33
+ const fromEnv = vars.some((v) => !!process.env[v]);
34
+ const fromSettings = !!settings.apiKeys?.[p];
35
+ const ok = hasProviderAuth(p, settings);
36
+ return (_jsxs(Text, { color: index === idx ? theme.primary : theme.text, children: [index === idx ? "› " : " ", ok ? "✓" : "○", " ", p, fromEnv && _jsx(Text, { color: theme.textMuted, children: " env" }), fromSettings && _jsx(Text, { color: theme.textMuted, children: " saved" })] }, p));
37
+ })] })] }) }));
32
38
  }
@@ -1,82 +1,48 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { memo } from "react";
3
3
  import { Box, Text } from "ink";
4
- const INNER = 42;
5
- const CHAR_COLORS = {
6
- "#": "gray",
7
- "@": "cyan",
8
- "$": "green",
9
- "%": "yellow",
10
- "^": "blue",
11
- "&": "magenta",
12
- "*": "white",
4
+ /**
5
+ * 5x7 block-letter glyphs for the "AGENT-DEV" wordmark.
6
+ * "" = lit pixel, " " = empty pixel. Every glyph is exactly 5 columns
7
+ * wide and 7 rows tall, so glyphs line up cleanly when concatenated
8
+ * row-by-row to spell out a word.
9
+ */
10
+ const GLYPH_HEIGHT = 7;
11
+ const GLYPH_WIDTH = 5;
12
+ const GLYPHS = {
13
+ A: [" █ ", " █ █ ", "█ █", "█████", "█ █", "█ █", "█ █"],
14
+ G: [" ███ ", "█ ", "█ ", "█ ███", "█ █", "█ █", " ███ "],
15
+ E: ["█████", "█ ", "█ ", "████ ", "█ ", "█ ", "█████"],
16
+ N: ["█ █", "██ █", "█ █ █", "█ ██", "█ █", "█ █", "█ █"],
17
+ T: ["█████", " █ ", " █ ", " █ ", " █ ", " █ ", " █ "],
18
+ "-": [" ", " ", " ", "█████", " ", " ", " "],
19
+ D: ["████ ", "█ █", "█ █", "█ █", "█ █", "█ █", "████ "],
20
+ V: ["█ █", "█ █", "█ █", "█ █", " █ █ ", " █ █ ", " █ "],
13
21
  };
14
- const BOX_CHARS = "+-|";
15
- function colorForChar(char, theme) {
16
- if (BOX_CHARS.includes(char) || char === " ")
17
- return theme.border;
18
- return CHAR_COLORS[char] ?? theme.text;
22
+ const BLANK_GLYPH = Array(GLYPH_HEIGHT).fill(" ".repeat(GLYPH_WIDTH));
23
+ /** Wordmark color — matches the brand logo regardless of active theme. */
24
+ const LOGO_COLOR = "#F2A154";
25
+ function glyphFor(char) {
26
+ return GLYPHS[char.toUpperCase()] ?? BLANK_GLYPH;
19
27
  }
20
- function ColoredLine({ line, theme }) {
21
- const segments = [];
22
- let run = "";
23
- let runColor = "";
24
- for (const char of line) {
25
- const color = colorForChar(char, theme);
26
- if (run && color !== runColor) {
27
- segments.push({ text: run, color: runColor });
28
- run = char;
29
- runColor = color;
30
- }
31
- else {
32
- run += char;
33
- runColor = color;
34
- }
28
+ /** Builds the word as GLYPH_HEIGHT lines of text, gap columns between letters. */
29
+ function buildBlockLines(word, gap = 1) {
30
+ const glyphs = word.split("").map(glyphFor);
31
+ const gapStr = " ".repeat(gap);
32
+ const lines = [];
33
+ for (let row = 0; row < GLYPH_HEIGHT; row++) {
34
+ lines.push(glyphs.map((g) => g[row]).join(gapStr));
35
35
  }
36
- if (run)
37
- segments.push({ text: run, color: runColor });
38
- return (_jsx(Text, { children: segments.map((s, i) => (_jsx(Text, { color: s.color, children: s.text }, i))) }));
36
+ return lines;
39
37
  }
40
- function center(text) {
41
- if (text.length >= INNER)
42
- return text.slice(0, INNER);
43
- const pad = INNER - text.length;
44
- const left = Math.floor(pad / 2);
45
- return " ".repeat(left) + text + " ".repeat(pad - left);
46
- }
47
- function row(content) {
48
- const inner = content.length > INNER ? content.slice(0, INNER) : content.padEnd(INNER);
49
- return "|" + inner + "|";
50
- }
51
- function border() {
52
- return "+" + "-".repeat(INNER) + "+";
53
- }
54
- function buildRobotLines() {
55
- // Every line in `head` is built to the same fixed width (23 chars) so
56
- // center() applies identical padding to each one and the face stays
57
- // aligned: antenna -> rounded head -> eyes -> mouth grille -> bolts.
58
- const head = [
59
- "." + "-".repeat(21) + ".", // head top, rounded corners
60
- "|" + " ".repeat(6) + "@@" + " ".repeat(5) + "@@" + " ".repeat(6) + "|", // eyes
61
- "|" + " ".repeat(21) + "|", // visor gap
62
- "|" + " ".repeat(6) + "# # # # #" + " ".repeat(6) + "|", // mouth grille
63
- "'" + "-".repeat(21) + "'", // head bottom, rounded corners
64
- " ".repeat(9) + "+" + " ".repeat(3) + "+" + " ".repeat(9), // neck bolts
65
- ];
66
- return [
67
- border(),
68
- row(center("#%@$^&* AGENT-DEV *&^$@#%")),
69
- row(""),
70
- ...head.map((line) => row(center(line))),
71
- row(""),
72
- border(),
73
- ];
74
- }
75
- const ROBOT_LINES = buildRobotLines();
76
- const ROBOT_MINI = "<@ @> AGENT-DEV <@ @>";
38
+ const LOGO_LINES = buildBlockLines("AGENT-DEV");
39
+ const LOGO_MINI = "AGENT-DEV";
77
40
  export const StartupBanner = memo(function StartupBanner({ theme, compact, }) {
41
+ // Falls back to the brand orange; lets a theme override via an
42
+ // optional `accent` field without forcing it into ThemeColors.
43
+ const color = theme.accent ?? LOGO_COLOR;
78
44
  if (compact) {
79
- return (_jsx(Box, { marginBottom: 1, children: _jsx(ColoredLine, { line: ROBOT_MINI, theme: theme }) }));
45
+ return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: color, bold: true, children: LOGO_MINI }) }));
80
46
  }
81
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: ROBOT_LINES.map((line, i) => (_jsx(ColoredLine, { line: line, theme: theme }, i))) }));
47
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: color, bold: true, children: line }, i))) }));
82
48
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devang0907/agent-dev",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Minimal terminal coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",