@devang0907/agent-dev 0.1.2 → 0.1.4

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,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
  }
@@ -4,7 +4,6 @@ import { Box, Text, useInput } from "ink";
4
4
  import { PROVIDER_LABELS } from "../config/models.js";
5
5
  import { PROVIDER_ENV_VARS } from "../providers/registry.js";
6
6
  import { LeftBorder } from "./LeftBorder.js";
7
- import { Panel } from "./Panel.js";
8
7
  function BlinkingCursor({ theme, visible }) {
9
8
  if (!visible)
10
9
  return null;
@@ -35,8 +34,8 @@ export function ApiKeyPrompt({ theme, provider, model, onSubmit, onCancel }) {
35
34
  if (input && !key.ctrl && !key.meta) {
36
35
  setValue((v) => v + input);
37
36
  }
38
- });
37
+ }, { isActive: true });
39
38
  const envVars = PROVIDER_ENV_VARS[provider];
40
39
  const masked = "•".repeat(value.length);
41
- return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.borderActive, children: [_jsx(Text, { color: theme.text, bold: true, children: "API key required" }), _jsx(Text, { color: theme.textMuted, children: " Enter save \u00B7 Esc back" }), _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, { marginTop: 1, children: _jsx(Panel, { theme: theme, borderColor: theme.primary, marginBottom: 0, children: _jsx(Box, { flexDirection: "row", 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\u2026" }), _jsx(BlinkingCursor, { theme: theme, visible: cursorOn })] })) }) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.textMuted, children: ["Or set env: ", envVars.join(" · ")] }) })] }) }));
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" })] })] }) }));
42
41
  }
package/dist/ui/App.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import type { AgentSession } from "../agent/session.js";
3
3
  export interface DisplayMessage {
4
+ id: number;
4
5
  role: "user" | "assistant" | "tool";
5
6
  content: string;
6
7
  toolName?: string;
package/dist/ui/App.js CHANGED
@@ -1,44 +1,98 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useRef } from "react";
3
- import { Box, useInput, useApp } from "ink";
3
+ import { Box, useInput, useApp, useStdout } from "ink";
4
4
  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
8
  import { ApiKeyPrompt } from "./ApiKeyPrompt.js";
9
9
  import { SettingsView } from "./SettingsView.js";
10
- import { hasProviderAuth } from "../providers/registry.js";
10
+ import { hasProviderAuth, getDefaultModelForProvider } from "../providers/registry.js";
11
+ import { findModel } from "../config/models.js";
12
+ import { CommandApprovalPrompt } from "./CommandApprovalPrompt.js";
11
13
  import { StartupBanner } from "./StartupBanner.js";
12
14
  import { getTheme } from "./theme.js";
13
- import { saveSettings } from "../config/settings.js";
15
+ import { scrollViewportToBottom } from "./scroll.js";
16
+ let nextMessageId = 0;
17
+ function toDisplayMessage(role, content, toolName) {
18
+ return { id: nextMessageId++, role, content, toolName };
19
+ }
20
+ function modelForProvider(provider, settings) {
21
+ const current = findModel(settings.defaultProvider, settings.defaultModel);
22
+ if (current?.provider === provider)
23
+ return current;
24
+ return getDefaultModelForProvider(provider);
25
+ }
14
26
  export function App({ session, workdir, onQuit }) {
15
27
  const { exit } = useApp();
16
- const [displayMessages, setDisplayMessages] = useState(() => session.getMessages().map((m) => ({
17
- role: m.role === "tool" ? "tool" : m.role === "user" ? "user" : "assistant",
18
- content: m.content,
19
- toolName: m.name,
20
- })));
28
+ const [displayMessages, setDisplayMessages] = useState(() => session.getMessages().map((m) => toDisplayMessage(m.role === "tool" ? "tool" : m.role === "user" ? "user" : "assistant", m.content, m.name)));
21
29
  const [streamingText, setStreamingText] = useState("");
22
30
  const [overlay, setOverlay] = useState("none");
23
31
  const [modelFilter, setModelFilter] = useState();
24
32
  const [pendingModel, setPendingModel] = useState(null);
33
+ const [apiKeyReturnOverlay, setApiKeyReturnOverlay] = useState("none");
25
34
  const [settings, setSettings] = useState(session.getSettings());
26
35
  const [model, setModel] = useState(session.getModel());
27
36
  const [running, setRunning] = useState(false);
37
+ const [autoFollow, setAutoFollow] = useState(true);
38
+ const [pendingCommand, setPendingCommand] = useState(null);
28
39
  const streamingRef = useRef("");
40
+ const autoFollowRef = useRef(true);
41
+ const startupChecked = useRef(false);
29
42
  const theme = getTheme();
43
+ const { stdout } = useStdout();
44
+ const openApiKeyPrompt = useCallback((target, returnTo = "none") => {
45
+ setPendingModel(target);
46
+ setApiKeyReturnOverlay(returnTo);
47
+ setOverlay("apiKey");
48
+ }, []);
49
+ const saveApiKey = useCallback((apiKey) => {
50
+ if (!pendingModel)
51
+ return;
52
+ const updated = {
53
+ ...settings,
54
+ apiKeys: { ...settings.apiKeys, [pendingModel.provider]: apiKey },
55
+ };
56
+ session.updateSettings(updated);
57
+ setSettings(updated);
58
+ session.setModel(pendingModel);
59
+ setModel(pendingModel);
60
+ setPendingModel(null);
61
+ setOverlay(apiKeyReturnOverlay);
62
+ setApiKeyReturnOverlay("none");
63
+ setModelFilter(undefined);
64
+ }, [pendingModel, settings, session, apiKeyReturnOverlay]);
65
+ useEffect(() => {
66
+ if (startupChecked.current)
67
+ return;
68
+ startupChecked.current = true;
69
+ const current = session.getModel();
70
+ if (!hasProviderAuth(current.provider, settings)) {
71
+ openApiKeyPrompt(current, "none");
72
+ }
73
+ }, [session, settings, openApiKeyPrompt]);
74
+ useEffect(() => {
75
+ if (autoFollow && streamingText) {
76
+ scrollViewportToBottom(stdout);
77
+ }
78
+ }, [streamingText, autoFollow, stdout]);
30
79
  useEffect(() => {
31
80
  const handler = (event) => {
32
81
  switch (event.type) {
33
82
  case "user_message":
34
- setDisplayMessages((prev) => [...prev, { role: "user", content: event.content }]);
83
+ autoFollowRef.current = true;
84
+ setAutoFollow(true);
85
+ setDisplayMessages((prev) => [...prev, toDisplayMessage("user", event.content)]);
35
86
  setRunning(true);
36
87
  streamingRef.current = "";
37
88
  setStreamingText("");
89
+ scrollViewportToBottom(stdout);
38
90
  break;
39
91
  case "message_start":
40
92
  streamingRef.current = "";
41
93
  setStreamingText("");
94
+ if (autoFollowRef.current)
95
+ scrollViewportToBottom(stdout);
42
96
  break;
43
97
  case "text_delta":
44
98
  streamingRef.current += event.delta;
@@ -47,7 +101,7 @@ export function App({ session, workdir, onQuit }) {
47
101
  case "tool_call":
48
102
  const partial = streamingRef.current;
49
103
  if (partial) {
50
- setDisplayMessages((prev) => [...prev, { role: "assistant", content: partial }]);
104
+ setDisplayMessages((prev) => [...prev, toDisplayMessage("assistant", partial)]);
51
105
  streamingRef.current = "";
52
106
  setStreamingText("");
53
107
  }
@@ -55,13 +109,13 @@ export function App({ session, workdir, onQuit }) {
55
109
  case "tool_result":
56
110
  setDisplayMessages((prev) => [
57
111
  ...prev,
58
- { role: "tool", content: event.result, toolName: event.name },
112
+ toDisplayMessage("tool", event.result, event.name),
59
113
  ]);
60
114
  break;
61
115
  case "turn_end":
62
116
  const final = streamingRef.current;
63
117
  if (final) {
64
- setDisplayMessages((prev) => [...prev, { role: "assistant", content: final }]);
118
+ setDisplayMessages((prev) => [...prev, toDisplayMessage("assistant", final)]);
65
119
  }
66
120
  streamingRef.current = "";
67
121
  setStreamingText("");
@@ -70,11 +124,19 @@ export function App({ session, workdir, onQuit }) {
70
124
  case "error":
71
125
  setDisplayMessages((prev) => [
72
126
  ...prev,
73
- { role: "assistant", content: `Error: ${event.message}` },
127
+ toDisplayMessage("assistant", `Error: ${event.message}`),
74
128
  ]);
75
129
  streamingRef.current = "";
76
130
  setStreamingText("");
77
131
  setRunning(false);
132
+ setPendingCommand(null);
133
+ if (/Missing .*API_KEY/i.test(event.message)) {
134
+ openApiKeyPrompt(model, "none");
135
+ }
136
+ break;
137
+ case "permission_request":
138
+ setPendingCommand(event.request);
139
+ setOverlay("commandApproval");
78
140
  break;
79
141
  case "model_changed":
80
142
  setModel(event.model);
@@ -85,14 +147,26 @@ export function App({ session, workdir, onQuit }) {
85
147
  return () => {
86
148
  session.off("event", handler);
87
149
  };
88
- }, [session]);
89
- useInput((_, key) => {
150
+ }, [session, model, openApiKeyPrompt, stdout]);
151
+ autoFollowRef.current = autoFollow;
152
+ useInput((input, key) => {
90
153
  if (overlay !== "none")
91
154
  return;
92
155
  if (key.escape && running) {
93
156
  session.abort();
157
+ return;
158
+ }
159
+ if (key.pageUp || (key.upArrow && key.shift)) {
160
+ autoFollowRef.current = false;
161
+ setAutoFollow(false);
162
+ return;
94
163
  }
95
- });
164
+ if (input === "g" && key.ctrl) {
165
+ autoFollowRef.current = true;
166
+ setAutoFollow(true);
167
+ scrollViewportToBottom(stdout);
168
+ }
169
+ }, { isActive: overlay === "none" });
96
170
  const handleSubmit = useCallback(async (value) => {
97
171
  if (value === "/quit") {
98
172
  onQuit();
@@ -103,6 +177,7 @@ export function App({ session, workdir, onQuit }) {
103
177
  session.newSession();
104
178
  setDisplayMessages([]);
105
179
  setStreamingText("");
180
+ setAutoFollow(true);
106
181
  return;
107
182
  }
108
183
  if (value === "/settings") {
@@ -117,13 +192,19 @@ export function App({ session, workdir, onQuit }) {
117
192
  }
118
193
  if (running)
119
194
  return;
195
+ if (!hasProviderAuth(model.provider, settings)) {
196
+ openApiKeyPrompt(model, "none");
197
+ return;
198
+ }
120
199
  await session.prompt(value);
121
- }, [session, running, onQuit, exit]);
200
+ }, [session, running, onQuit, exit, model, settings, openApiKeyPrompt]);
122
201
  const hasChat = displayMessages.length > 0 || streamingText.length > 0;
123
- 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) => {
202
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { paddingX: 2, marginBottom: 1, flexShrink: 0, children: _jsx(StartupBanner, { theme: theme, compact: hasChat }) }), _jsx(ChatView, { messages: displayMessages, theme: theme, model: model, streamingText: streamingText, running: running, autoFollow: autoFollow }), _jsx(Footer, { workdir: workdir, model: model, theme: theme }), overlay === "none" && (_jsx(Box, { flexShrink: 0, children: _jsx(Editor, { theme: theme, model: model, disabled: running, running: running, onSubmit: handleSubmit, onPauseFollow: () => {
203
+ autoFollowRef.current = false;
204
+ setAutoFollow(false);
205
+ } }) })), overlay === "model" && (_jsx(ModelSelector, { theme: theme, settings: settings, filter: modelFilter, onSelect: (m) => {
124
206
  if (!hasProviderAuth(m.provider, settings)) {
125
- setPendingModel(m);
126
- setOverlay("apiKey");
207
+ openApiKeyPrompt(m, "model");
127
208
  return;
128
209
  }
129
210
  session.setModel(m);
@@ -133,24 +214,22 @@ export function App({ session, workdir, onQuit }) {
133
214
  }, onClose: () => {
134
215
  setOverlay("none");
135
216
  setModelFilter(undefined);
136
- } })), overlay === "apiKey" && pendingModel && (_jsx(ApiKeyPrompt, { theme: theme, provider: pendingModel.provider, model: pendingModel, onSubmit: (apiKey) => {
137
- const updated = {
138
- ...settings,
139
- apiKeys: { ...settings.apiKeys, [pendingModel.provider]: apiKey },
140
- };
141
- session.updateSettings(updated);
142
- setSettings(updated);
143
- session.setModel(pendingModel);
144
- setModel(pendingModel);
217
+ } })), overlay === "apiKey" && pendingModel && (_jsx(ApiKeyPrompt, { theme: theme, provider: pendingModel.provider, model: pendingModel, onSubmit: saveApiKey, onCancel: () => {
145
218
  setPendingModel(null);
146
- setOverlay("none");
147
- setModelFilter(undefined);
148
- }, onCancel: () => {
149
- setPendingModel(null);
150
- setOverlay("model");
219
+ setOverlay(apiKeyReturnOverlay);
220
+ setApiKeyReturnOverlay("none");
151
221
  } })), overlay === "settings" && (_jsx(SettingsView, { theme: theme, settings: settings, onUpdate: (s) => {
152
222
  session.updateSettings(s);
153
223
  setSettings(s);
154
- saveSettings(s);
155
- }, onClose: () => setOverlay("none") }))] }));
224
+ }, onSetApiKey: (provider) => {
225
+ openApiKeyPrompt(modelForProvider(provider, settings), "settings");
226
+ }, onClose: () => setOverlay("none") })), overlay === "commandApproval" && pendingCommand && (_jsx(CommandApprovalPrompt, { theme: theme, request: pendingCommand, onApprove: () => {
227
+ session.respondToPermission(true);
228
+ setPendingCommand(null);
229
+ setOverlay("none");
230
+ }, onDeny: () => {
231
+ session.respondToPermission(false);
232
+ setPendingCommand(null);
233
+ setOverlay("none");
234
+ } }))] }));
156
235
  }
@@ -8,6 +8,7 @@ interface ChatViewProps {
8
8
  model: Model;
9
9
  streamingText?: string;
10
10
  running?: boolean;
11
+ autoFollow?: boolean;
11
12
  }
12
- export declare function ChatView({ messages, theme, model, streamingText, running }: ChatViewProps): React.JSX.Element | null;
13
+ export declare function ChatView({ messages, theme, model, streamingText, running, autoFollow, }: ChatViewProps): React.JSX.Element | null;
13
14
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from "react";
3
- import { Box, Text } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Static, Text } from "ink";
4
4
  import { SPINNER_FRAMES, TOOL_ICONS } from "./theme.js";
5
5
  import { LeftBorder } from "./LeftBorder.js";
6
6
  import { Panel } from "./Panel.js";
@@ -8,7 +8,16 @@ import { modelRef } from "../config/models.js";
8
8
  function truncate(text, max) {
9
9
  return text.length > max ? text.slice(0, max) + "…" : text;
10
10
  }
11
- export function ChatView({ messages, theme, model, streamingText, running }) {
11
+ function StaticMessage({ msg, theme, model, }) {
12
+ if (msg.role === "user") {
13
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(LeftBorder, { theme: theme, borderColor: theme.primary, marginBottom: 0, children: _jsx(Text, { color: theme.text, children: msg.content }) }) }));
14
+ }
15
+ if (msg.role === "assistant") {
16
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 1, children: [_jsx(Text, { color: theme.text, children: msg.content || "" }), _jsxs(Text, { color: theme.textMuted, children: [_jsx(Text, { color: theme.primary, children: "\u25A3 " }), modelRef(model)] })] }));
17
+ }
18
+ return (_jsx(Box, { paddingLeft: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.text, children: [_jsx(Text, { color: theme.textMuted, children: TOOL_ICONS[msg.toolName ?? ""] ?? "⚙" }), " ", _jsx(Text, { bold: true, children: msg.toolName }), _jsxs(Text, { color: theme.textMuted, children: [" ", truncate(msg.content, 500)] })] }) }));
19
+ }
20
+ export function ChatView({ messages, theme, model, streamingText, running, autoFollow = true, }) {
12
21
  const [spinIdx, setSpinIdx] = useState(0);
13
22
  const hasContent = messages.length > 0 || (streamingText?.length ?? 0) > 0;
14
23
  useEffect(() => {
@@ -20,5 +29,6 @@ export function ChatView({ messages, theme, model, streamingText, running }) {
20
29
  if (!hasContent) {
21
30
  return null;
22
31
  }
23
- return (_jsxs(Panel, { theme: theme, flexGrow: 1, borderColor: theme.border, children: [messages.map((msg, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [msg.role === "user" && (_jsx(LeftBorder, { theme: theme, borderColor: theme.primary, marginBottom: 0, children: _jsx(Text, { color: theme.text, children: msg.content }) })), msg.role === "assistant" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.text, children: msg.content || "" }), _jsxs(Text, { color: theme.textMuted, children: [_jsx(Text, { color: theme.primary, children: "\u25A3 " }), modelRef(model)] })] })), msg.role === "tool" && (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: theme.text, children: [_jsx(Text, { color: theme.textMuted, children: TOOL_ICONS[msg.toolName ?? ""] ?? "⚙" }), " ", _jsx(Text, { bold: true, children: msg.toolName }), _jsxs(Text, { color: theme.textMuted, children: [" ", truncate(msg.content, 200)] })] }) }))] }, i))), streamingText && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.text, children: streamingText }), _jsxs(Text, { color: theme.textMuted, children: [_jsxs(Text, { color: theme.primary, children: [SPINNER_FRAMES[spinIdx], " "] }), "responding\u2026"] })] })), running && !streamingText && messages.length > 0 && (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: theme.textMuted, children: [_jsx(Text, { color: theme.primary, children: SPINNER_FRAMES[spinIdx] }), " working\u2026"] }) }))] }));
32
+ const showWorking = running && !streamingText && messages.length > 0;
33
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Static, { items: messages, children: (msg) => (_jsx(StaticMessage, { msg: msg, theme: theme, model: model }, msg.id)) }), (streamingText || showWorking) && (_jsxs(Panel, { theme: theme, borderColor: theme.border, marginBottom: 1, children: [streamingText && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.text, children: streamingText }), _jsxs(Text, { color: theme.textMuted, children: [_jsxs(Text, { color: theme.primary, children: [SPINNER_FRAMES[spinIdx], " "] }), "responding\u2026", !autoFollow && (_jsx(Text, { color: theme.warning, children: " \u00B7 paused (Ctrl+G to follow)" }))] })] })), showWorking && (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: theme.textMuted, children: [_jsx(Text, { color: theme.primary, children: SPINNER_FRAMES[spinIdx] }), " working\u2026"] }) }))] }))] }));
24
34
  }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import type { ThemeColors } from "./theme.js";
3
+ import type { PermissionRequest } from "../agent/loop.js";
4
+ interface CommandApprovalPromptProps {
5
+ theme: ThemeColors;
6
+ request: PermissionRequest;
7
+ onApprove: () => void;
8
+ onDeny: () => void;
9
+ }
10
+ export declare function CommandApprovalPrompt({ theme, request, onApprove, onDeny, }: CommandApprovalPromptProps): React.JSX.Element;
11
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { LeftBorder } from "./LeftBorder.js";
4
+ export function CommandApprovalPrompt({ theme, request, onApprove, onDeny, }) {
5
+ useInput((input, key) => {
6
+ if (input === "y" || input === "Y") {
7
+ onApprove();
8
+ return;
9
+ }
10
+ if (input === "n" || input === "N" || key.escape) {
11
+ onDeny();
12
+ }
13
+ }, { isActive: true });
14
+ return (_jsx(Box, { flexDirection: "column", marginX: 2, marginTop: 1, marginBottom: 1, children: _jsxs(LeftBorder, { theme: theme, borderColor: theme.warning, children: [_jsx(Text, { color: theme.text, bold: true, children: "Run shell command?" }), _jsx(Text, { color: theme.textMuted, children: " y approve \u00B7 n or Esc deny" }), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: theme.warning, paddingX: 1, paddingY: 0, children: _jsx(Text, { color: theme.text, children: request.command }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.textMuted, children: "The agent wants to run this command in your project directory." }) })] }) }));
15
+ }
@@ -7,6 +7,7 @@ interface EditorProps {
7
7
  disabled?: boolean;
8
8
  running?: boolean;
9
9
  onSubmit: (value: string) => void;
10
+ onPauseFollow?: () => void;
10
11
  }
11
- export declare function Editor({ theme, model, disabled, running, onSubmit }: EditorProps): React.JSX.Element;
12
+ export declare function Editor({ theme, model, disabled, running, onSubmit, onPauseFollow }: EditorProps): React.JSX.Element;
12
13
  export {};
package/dist/ui/Editor.js CHANGED
@@ -10,7 +10,7 @@ function BlinkingCursor({ theme, visible }) {
10
10
  return null;
11
11
  return _jsx(Text, { color: theme.primary, children: "\u258C" });
12
12
  }
13
- export function Editor({ theme, model, disabled, running, onSubmit }) {
13
+ export function Editor({ theme, model, disabled, running, onSubmit, onPauseFollow }) {
14
14
  const [value, setValue] = useState("");
15
15
  const [suggestions, setSuggestions] = useState([]);
16
16
  const [spinIdx, setSpinIdx] = useState(0);
@@ -60,6 +60,10 @@ export function Editor({ theme, model, disabled, running, onSubmit }) {
60
60
  }
61
61
  return;
62
62
  }
63
+ if ((key.pageUp || key.upArrow) && !key.ctrl && !key.meta) {
64
+ onPauseFollow?.();
65
+ return;
66
+ }
63
67
  if (key.backspace || key.delete) {
64
68
  const newVal = value.slice(0, -1);
65
69
  setValue(newVal);
@@ -71,8 +75,8 @@ export function Editor({ theme, model, disabled, running, onSubmit }) {
71
75
  setValue(newVal);
72
76
  updateSuggestions(newVal);
73
77
  }
74
- });
78
+ }, { isActive: !disabled });
75
79
  const placeholder = "Ask anything…";
76
80
  const showCursor = !disabled && cursorOn;
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" })) })] }));
81
+ 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 \u00B7 scroll freely \u00B7 Ctrl+G follow" })) })] }));
78
82
  }
@@ -30,7 +30,7 @@ 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
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) => {
@@ -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
  }
@@ -0,0 +1,6 @@
1
+ export interface TerminalSize {
2
+ rows: number;
3
+ cols: number;
4
+ }
5
+ export declare function useTerminalSize(): TerminalSize;
6
+ export declare function chatContentWidth(cols: number): number;
@@ -0,0 +1,22 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+ export function useTerminalSize() {
4
+ const { stdout } = useStdout();
5
+ const [size, setSize] = useState({
6
+ rows: stdout.rows,
7
+ cols: stdout.columns,
8
+ });
9
+ useEffect(() => {
10
+ const onResize = () => {
11
+ setSize({ rows: stdout.rows, cols: stdout.columns });
12
+ };
13
+ stdout.on("resize", onResize);
14
+ return () => {
15
+ stdout.off("resize", onResize);
16
+ };
17
+ }, [stdout]);
18
+ return size;
19
+ }
20
+ export function chatContentWidth(cols) {
21
+ return Math.max(20, cols - 12);
22
+ }
@@ -0,0 +1,3 @@
1
+ import type { WriteStream } from "node:tty";
2
+ /** Nudge the terminal viewport toward the latest output (best-effort across terminals). */
3
+ export declare function scrollViewportToBottom(stdout: WriteStream): void;
@@ -0,0 +1,4 @@
1
+ /** Nudge the terminal viewport toward the latest output (best-effort across terminals). */
2
+ export function scrollViewportToBottom(stdout) {
3
+ stdout.write("\x1b[999T");
4
+ }