@xingjisoft/neutrino 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,196 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const PKG_ROOT = path.resolve(__dirname, "..");
7
+ export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
8
+ export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
9
+ export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
10
+ export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
11
+ export const DEFAULT_MODELS = {
12
+ providers: [
13
+ {
14
+ name: "DeepSeek",
15
+ models: [
16
+ { name: "deepseek-v4-pro", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true },
17
+ { name: "deepseek-chat", url: "https://api.deepseek.com/v1", api_key: "", supports_streaming: true },
18
+ ],
19
+ },
20
+ {
21
+ name: "OpenAI",
22
+ models: [
23
+ { name: "gpt-4o", url: "https://api.openai.com/v1", api_key: "", supports_streaming: true },
24
+ { name: "gpt-4o-mini", url: "https://api.openai.com/v1", api_key: "", supports_streaming: true },
25
+ ],
26
+ },
27
+ {
28
+ name: "Anthropic",
29
+ models: [
30
+ { name: "claude-3-5-sonnet-20241022", url: "https://api.anthropic.com/v1", api_key: "", supports_streaming: true },
31
+ ],
32
+ },
33
+ ],
34
+ };
35
+ export const DEFAULT_CONFIG = {
36
+ language: "en",
37
+ theme: "default",
38
+ default_intensity: "High",
39
+ };
40
+ export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
41
+ export const INTENSITY_PARAMS = {
42
+ Low: { temperature: 0.1, top_p: 0.1 },
43
+ Medium: { temperature: 0.3, top_p: 0.5 },
44
+ High: { temperature: 0.7, top_p: 0.9 },
45
+ Max: { temperature: 1.0, top_p: 1.0 },
46
+ Ultra: { temperature: 1.5, top_p: 1.0 },
47
+ };
48
+ export function ensureDirs() {
49
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
50
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
51
+ }
52
+ export function loadModels() {
53
+ ensureDirs();
54
+ if (!fs.existsSync(MODEL_FILE)) {
55
+ saveModels(DEFAULT_MODELS);
56
+ return DEFAULT_MODELS;
57
+ }
58
+ try {
59
+ return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
60
+ }
61
+ catch {
62
+ return DEFAULT_MODELS;
63
+ }
64
+ }
65
+ export function saveModels(data) {
66
+ ensureDirs();
67
+ fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
68
+ }
69
+ export function loadConfig() {
70
+ ensureDirs();
71
+ if (!fs.existsSync(CONFIG_FILE)) {
72
+ saveConfig(DEFAULT_CONFIG);
73
+ return { ...DEFAULT_CONFIG };
74
+ }
75
+ try {
76
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
77
+ }
78
+ catch {
79
+ return { ...DEFAULT_CONFIG };
80
+ }
81
+ }
82
+ export function saveConfig(data) {
83
+ ensureDirs();
84
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
85
+ }
86
+ export function getModelsByProvider() {
87
+ const data = loadModels();
88
+ const result = {};
89
+ for (const provider of data.providers ?? []) {
90
+ result[provider.name] = provider.models ?? [];
91
+ }
92
+ return result;
93
+ }
94
+ export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens) {
95
+ ensureDirs();
96
+ const now = new Date();
97
+ const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-");
98
+ const existing = sessionId ? loadSession(sessionId) : null;
99
+ const created_at = existing ? existing.created_at : now.toISOString();
100
+ let title = "";
101
+ for (const msg of messages) {
102
+ if (msg.role === "user") {
103
+ title = (msg.content ?? "").slice(0, 80);
104
+ break;
105
+ }
106
+ }
107
+ const session = {
108
+ id,
109
+ created_at,
110
+ updated_at: now.toISOString(),
111
+ model: modelName,
112
+ provider,
113
+ intensity,
114
+ cwd: cwd ?? process.cwd(),
115
+ title,
116
+ messages,
117
+ tokens,
118
+ };
119
+ const filePath = path.join(SESSION_DIR, `${id}.json`);
120
+ fs.writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
121
+ return id;
122
+ }
123
+ export function loadSession(sessionId) {
124
+ const filePath = path.join(SESSION_DIR, `${sessionId}.json`);
125
+ if (!fs.existsSync(filePath))
126
+ return null;
127
+ try {
128
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ export function deleteSession(sessionId) {
135
+ const filePath = path.join(SESSION_DIR, `${sessionId}.json`);
136
+ if (fs.existsSync(filePath)) {
137
+ fs.unlinkSync(filePath);
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+ export function listSessions() {
143
+ ensureDirs();
144
+ const sessions = [];
145
+ let files = [];
146
+ try {
147
+ files = fs.readdirSync(SESSION_DIR).filter((f) => f.endsWith(".json"));
148
+ }
149
+ catch {
150
+ return sessions;
151
+ }
152
+ for (const f of files) {
153
+ try {
154
+ const data = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, f), "utf-8"));
155
+ sessions.push({
156
+ id: data.id ?? f.replace(/\.json$/, ""),
157
+ created_at: data.created_at ?? "",
158
+ updated_at: data.updated_at ?? data.created_at ?? "",
159
+ model: data.model ?? "",
160
+ provider: data.provider ?? "",
161
+ intensity: data.intensity ?? "",
162
+ cwd: data.cwd ?? "",
163
+ title: data.title ?? "",
164
+ message_count: (data.messages ?? []).length,
165
+ });
166
+ }
167
+ catch {
168
+ // ignore unreadable sessions
169
+ }
170
+ }
171
+ sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
172
+ return sessions;
173
+ }
174
+ function readAsset(...candidates) {
175
+ for (const candidate of candidates) {
176
+ try {
177
+ if (fs.existsSync(candidate)) {
178
+ return fs
179
+ .readFileSync(candidate, "utf-8")
180
+ .replace(/\r\n/g, "\n")
181
+ .replace(/\r/g, "\n");
182
+ }
183
+ }
184
+ catch {
185
+ // continue
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ export function loadLogo() {
191
+ return (readAsset(path.join(PKG_ROOT, "logo.txt"), path.join(PKG_ROOT, "assets", "logo.txt")) ?? "NEUTRINO");
192
+ }
193
+ export function loadPrompt() {
194
+ return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
195
+ "You are Neutrino, a helpful AI coding assistant.");
196
+ }
package/dist/header.js ADDED
@@ -0,0 +1,15 @@
1
+ import { C } from "./colors.js";
2
+ import { truncateByWidth } from "./width.js";
3
+ export function drawHeader(buf, state) {
4
+ const w = buf.width;
5
+ buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
6
+ let text;
7
+ if (state.isThinking) {
8
+ text = "thinking";
9
+ }
10
+ else {
11
+ text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
12
+ }
13
+ text = truncateByWidth(text, w);
14
+ buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
15
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { App } from "./app.js";
2
+ export { defaultState } from "./state.js";
3
+ export { INTENSITY_LEVELS, INTENSITY_PARAMS, DEFAULT_MODELS, loadModels, saveModels, loadConfig, saveConfig, listSessions, loadSession, deleteSession, saveSession, } from "./config.js";
4
+ export { LLMClient } from "./llm/client.js";
5
+ export { executeTool, getToolDefinitions } from "./llm/tools.js";
@@ -0,0 +1,205 @@
1
+ import { INTENSITY_PARAMS } from "../config.js";
2
+ export class LLMClient {
3
+ model;
4
+ provider;
5
+ intensity;
6
+ temperature;
7
+ top_p;
8
+ toolCalls = [];
9
+ usage = null;
10
+ constructor(model, provider, intensity) {
11
+ this.model = model;
12
+ this.provider = provider;
13
+ this.intensity = intensity;
14
+ const params = INTENSITY_PARAMS[intensity] ?? INTENSITY_PARAMS["High"];
15
+ this.temperature = params.temperature;
16
+ this.top_p = params.top_p;
17
+ }
18
+ async *streamTurn(messages, tools) {
19
+ this.toolCalls = [];
20
+ this.usage = null;
21
+ try {
22
+ if (this.provider === "Anthropic") {
23
+ yield* this.streamAnthropic(messages);
24
+ return;
25
+ }
26
+ yield* this.streamOpenAI(messages, tools);
27
+ }
28
+ catch (e) {
29
+ yield `\n[Error: ${e instanceof Error ? e.message : String(e)}]`;
30
+ }
31
+ }
32
+ async *streamOpenAI(messages, tools) {
33
+ const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
34
+ const headers = {
35
+ "Content-Type": "application/json",
36
+ Authorization: `Bearer ${this.model.api_key}`,
37
+ };
38
+ const payload = {
39
+ model: this.model.name,
40
+ messages,
41
+ temperature: this.temperature,
42
+ top_p: this.top_p,
43
+ stream: true,
44
+ stream_options: { include_usage: true },
45
+ };
46
+ if (tools && tools.length)
47
+ payload.tools = tools;
48
+ const resp = await fetch(url, {
49
+ method: "POST",
50
+ headers,
51
+ body: JSON.stringify(payload),
52
+ });
53
+ if (!resp.ok) {
54
+ const text = await resp.text().catch(() => "");
55
+ yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
56
+ return;
57
+ }
58
+ if (!resp.body) {
59
+ yield "\n[Error: empty response body]";
60
+ return;
61
+ }
62
+ const toolCallsByIndex = {};
63
+ const reader = resp.body.getReader();
64
+ const decoder = new TextDecoder();
65
+ let buf = "";
66
+ let stopped = false;
67
+ while (!stopped) {
68
+ const { done, value } = await reader.read();
69
+ if (done)
70
+ break;
71
+ buf += decoder.decode(value, { stream: true });
72
+ let idx;
73
+ while ((idx = buf.indexOf("\n")) >= 0) {
74
+ const raw = buf.slice(0, idx).replace(/\r$/, "");
75
+ buf = buf.slice(idx + 1);
76
+ const line = raw.trim();
77
+ if (!line)
78
+ continue;
79
+ if (!line.startsWith("data:"))
80
+ continue;
81
+ const data = line.slice(5).trim();
82
+ if (data === "[DONE]") {
83
+ stopped = true;
84
+ break;
85
+ }
86
+ let chunk;
87
+ try {
88
+ chunk = JSON.parse(data);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ const usage = chunk.usage;
94
+ if (usage) {
95
+ this.usage = {
96
+ prompt: usage.prompt_tokens ?? 0,
97
+ completion: usage.completion_tokens ?? 0,
98
+ total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
99
+ };
100
+ }
101
+ const choice = chunk.choices?.[0];
102
+ const delta = choice?.delta ?? {};
103
+ const content = delta.content;
104
+ if (typeof content === "string" && content)
105
+ yield content;
106
+ const toolCalls = delta.tool_calls;
107
+ if (toolCalls) {
108
+ for (const tc of toolCalls) {
109
+ const i = tc.index ?? 0;
110
+ const entry = (toolCallsByIndex[i] ??= { id: "", name: "", arguments: "" });
111
+ if (tc.id)
112
+ entry.id = tc.id;
113
+ if (tc.function?.name)
114
+ entry.name = tc.function.name;
115
+ if (tc.function?.arguments)
116
+ entry.arguments += tc.function.arguments;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ this.toolCalls = Object.keys(toolCallsByIndex)
122
+ .map((k) => Number(k))
123
+ .sort((a, b) => a - b)
124
+ .map((k) => toolCallsByIndex[k]);
125
+ }
126
+ async *streamAnthropic(messages) {
127
+ const url = this.model.url.replace(/\/+$/, "") + "/messages";
128
+ const headers = {
129
+ "Content-Type": "application/json",
130
+ "x-api-key": this.model.api_key,
131
+ "anthropic-version": "2023-06-01",
132
+ };
133
+ let systemMsg;
134
+ const chatMessages = [];
135
+ for (const m of messages) {
136
+ if (m.role === "system")
137
+ systemMsg = m.content ?? "";
138
+ else
139
+ chatMessages.push({ role: m.role, content: m.content ?? "" });
140
+ }
141
+ const payload = {
142
+ model: this.model.name,
143
+ messages: chatMessages,
144
+ max_tokens: 4096,
145
+ temperature: this.temperature,
146
+ stream: true,
147
+ };
148
+ if (systemMsg)
149
+ payload.system = systemMsg;
150
+ const resp = await fetch(url, {
151
+ method: "POST",
152
+ headers,
153
+ body: JSON.stringify(payload),
154
+ });
155
+ if (!resp.ok) {
156
+ const text = await resp.text().catch(() => "");
157
+ yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
158
+ return;
159
+ }
160
+ if (!resp.body) {
161
+ yield "\n[Error: empty response body]";
162
+ return;
163
+ }
164
+ const reader = resp.body.getReader();
165
+ const decoder = new TextDecoder();
166
+ let buf = "";
167
+ let stopped = false;
168
+ while (!stopped) {
169
+ const { done, value } = await reader.read();
170
+ if (done)
171
+ break;
172
+ buf += decoder.decode(value, { stream: true });
173
+ let idx;
174
+ while ((idx = buf.indexOf("\n")) >= 0) {
175
+ const raw = buf.slice(0, idx).replace(/\r$/, "");
176
+ buf = buf.slice(idx + 1);
177
+ const line = raw.trim();
178
+ if (!line || !line.startsWith("data:"))
179
+ continue;
180
+ const data = line.slice(5).trim();
181
+ if (data === "[DONE]") {
182
+ stopped = true;
183
+ break;
184
+ }
185
+ let chunk;
186
+ try {
187
+ chunk = JSON.parse(data);
188
+ }
189
+ catch {
190
+ continue;
191
+ }
192
+ const type = chunk.type;
193
+ if (type === "content_block_delta") {
194
+ const text = chunk.delta?.text;
195
+ if (text)
196
+ yield text;
197
+ }
198
+ else if (type === "message_stop") {
199
+ stopped = true;
200
+ break;
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,169 @@
1
+ import { exec } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const MAX_OUTPUT = 20000;
5
+ const MAX_FILE_SIZE = 200000;
6
+ export function getToolDefinitions() {
7
+ return [
8
+ {
9
+ type: "function",
10
+ function: {
11
+ name: "run_command",
12
+ description: "Execute a shell command in the current working directory and return its stdout/stderr.",
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ command: { type: "string", description: "The shell command to execute." },
17
+ },
18
+ required: ["command"],
19
+ },
20
+ },
21
+ },
22
+ {
23
+ type: "function",
24
+ function: {
25
+ name: "read_file",
26
+ description: "Read the content of a text file. Returns the file content or an error.",
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ path: { type: "string", description: "Path to the file to read (absolute or relative to cwd)." },
31
+ },
32
+ required: ["path"],
33
+ },
34
+ },
35
+ },
36
+ {
37
+ type: "function",
38
+ function: {
39
+ name: "write_file",
40
+ description: "Write content to a file, creating or overwriting it.",
41
+ parameters: {
42
+ type: "object",
43
+ properties: {
44
+ path: { type: "string", description: "Path to the file to write (absolute or relative to cwd)." },
45
+ content: { type: "string", description: "The full content to write to the file." },
46
+ },
47
+ required: ["path", "content"],
48
+ },
49
+ },
50
+ },
51
+ {
52
+ type: "function",
53
+ function: {
54
+ name: "list_files",
55
+ description: "List files and directories in a directory.",
56
+ parameters: {
57
+ type: "object",
58
+ properties: {
59
+ path: { type: "string", description: "Directory to list (defaults to current working directory)." },
60
+ },
61
+ required: [],
62
+ },
63
+ },
64
+ },
65
+ ];
66
+ }
67
+ export async function executeTool(name, args) {
68
+ let result;
69
+ try {
70
+ switch (name) {
71
+ case "run_command":
72
+ result = await runCommand(String(args.command ?? ""));
73
+ break;
74
+ case "read_file":
75
+ result = readFile(String(args.path ?? ""));
76
+ break;
77
+ case "write_file":
78
+ result = writeFile(String(args.path ?? ""), String(args.content ?? ""));
79
+ break;
80
+ case "list_files":
81
+ result = listFiles(String(args.path ?? ""));
82
+ break;
83
+ default:
84
+ result = `[Error: unknown tool '${name}']`;
85
+ }
86
+ }
87
+ catch (e) {
88
+ result = `[Error: ${e instanceof Error ? e.message : String(e)}]`;
89
+ }
90
+ return result.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
91
+ }
92
+ function runCommand(command) {
93
+ return new Promise((resolve) => {
94
+ if (!command.trim()) {
95
+ resolve("[Error: empty command]");
96
+ return;
97
+ }
98
+ exec(command, { cwd: process.cwd(), timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
99
+ const parts = [];
100
+ if (stdout)
101
+ parts.push(stdout);
102
+ if (stderr)
103
+ parts.push("[stderr]\n" + stderr);
104
+ if (error) {
105
+ const code = error.code;
106
+ if (code === "ETIMEDOUT" || error.killed) {
107
+ parts.push("[Error: command timed out]");
108
+ }
109
+ else {
110
+ const exitCode = error.code;
111
+ parts.push(`[exit code: ${exitCode ?? "unknown"}]`);
112
+ }
113
+ }
114
+ const output = parts.join("\n").trim();
115
+ resolve(output ? output.slice(0, MAX_OUTPUT) : "(no output)");
116
+ });
117
+ });
118
+ }
119
+ function readFile(p) {
120
+ const resolved = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
121
+ if (!fs.existsSync(resolved))
122
+ return `[Error: file not found: ${resolved}]`;
123
+ if (fs.statSync(resolved).isDirectory())
124
+ return `[Error: ${resolved} is a directory]`;
125
+ try {
126
+ let data = fs.readFileSync(resolved, "utf-8");
127
+ if (data.length > MAX_FILE_SIZE)
128
+ data = data.slice(0, MAX_FILE_SIZE) + "\n...[truncated]";
129
+ return data;
130
+ }
131
+ catch (e) {
132
+ return `[Error: ${e instanceof Error ? e.message : String(e)}]`;
133
+ }
134
+ }
135
+ function writeFile(p, content) {
136
+ const resolved = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
137
+ try {
138
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
139
+ fs.writeFileSync(resolved, content, "utf-8");
140
+ return `Wrote ${content.length} characters to ${resolved}`;
141
+ }
142
+ catch (e) {
143
+ return `[Error: ${e instanceof Error ? e.message : String(e)}]`;
144
+ }
145
+ }
146
+ function listFiles(p) {
147
+ const resolved = p.trim()
148
+ ? path.isAbsolute(p)
149
+ ? p
150
+ : path.join(process.cwd(), p)
151
+ : process.cwd();
152
+ if (!fs.existsSync(resolved))
153
+ return `[Error: directory not found: ${resolved}]`;
154
+ if (!fs.statSync(resolved).isDirectory())
155
+ return `[Error: ${resolved} is not a directory]`;
156
+ try {
157
+ const entries = fs
158
+ .readdirSync(resolved)
159
+ .map((name) => {
160
+ const isDir = fs.statSync(path.join(resolved, name)).isDirectory();
161
+ return name + (isDir ? "/" : "");
162
+ })
163
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
164
+ return entries.join("\n") || "(empty directory)";
165
+ }
166
+ catch (e) {
167
+ return `[Error: ${e instanceof Error ? e.message : String(e)}]`;
168
+ }
169
+ }
@@ -0,0 +1,98 @@
1
+ import { Screen } from "../terminal/screen.js";
2
+ import { C } from "../colors.js";
3
+ import { drawHeader } from "../header.js";
4
+ import { wrapText } from "../ui.js";
5
+ import { ellipsize } from "../width.js";
6
+ export class AgentConversationScreen extends Screen {
7
+ title;
8
+ messages;
9
+ scrollTop = 0;
10
+ followBottom = true;
11
+ constructor(title, messages) {
12
+ super();
13
+ this.title = title;
14
+ this.messages = messages;
15
+ }
16
+ onKey(key) {
17
+ if (key.name === "up") {
18
+ this.followBottom = false;
19
+ this.scrollTop = Math.max(0, this.scrollTop - 1);
20
+ this.app.render();
21
+ }
22
+ else if (key.name === "down") {
23
+ this.scrollTop += 1;
24
+ this.app.render();
25
+ }
26
+ else if (key.name === "pageup") {
27
+ this.followBottom = false;
28
+ this.scrollTop = Math.max(0, this.scrollTop - 10);
29
+ this.app.render();
30
+ }
31
+ else if (key.name === "pagedown") {
32
+ this.scrollTop += 10;
33
+ this.app.render();
34
+ }
35
+ else if (key.name === "escape") {
36
+ this.app.popScreen();
37
+ }
38
+ }
39
+ onMouse(ev) {
40
+ if (ev.type === "wheel") {
41
+ if (ev.dir === -1) {
42
+ this.followBottom = false;
43
+ this.scrollTop = Math.max(0, this.scrollTop - 3);
44
+ }
45
+ else {
46
+ this.scrollTop += 3;
47
+ }
48
+ this.app.render();
49
+ }
50
+ }
51
+ buildLines(width) {
52
+ const lines = [];
53
+ const push = (text, style) => {
54
+ for (const ln of wrapText(text, width))
55
+ lines.push({ text: ln, style });
56
+ };
57
+ for (const m of this.messages) {
58
+ if (m.role === "user") {
59
+ lines.push({ text: "You:", style: { fg: C.accent, bold: true } });
60
+ push(m.content, { fg: C.user });
61
+ lines.push({ text: "", style: {} });
62
+ }
63
+ else {
64
+ lines.push({ text: "Assistant:", style: { fg: C.accent, bold: true } });
65
+ push(m.content, { fg: C.assistant });
66
+ lines.push({ text: "", style: {} });
67
+ }
68
+ }
69
+ return lines;
70
+ }
71
+ render(buf) {
72
+ drawHeader(buf, this.app.state);
73
+ const w = buf.width;
74
+ const h = buf.height;
75
+ buf.centerText(1, ellipsize(this.title, Math.max(10, w - 4)), { fg: C.dim });
76
+ const logTop = 3;
77
+ const logBottom = h - 2;
78
+ const viewH = Math.max(1, logBottom - logTop + 1);
79
+ const lines = this.buildLines(Math.max(10, w - 4));
80
+ const maxScroll = Math.max(0, lines.length - viewH);
81
+ if (this.followBottom)
82
+ this.scrollTop = maxScroll;
83
+ if (this.scrollTop > maxScroll) {
84
+ this.scrollTop = maxScroll;
85
+ this.followBottom = true;
86
+ }
87
+ if (this.scrollTop < 0)
88
+ this.scrollTop = 0;
89
+ for (let y = logTop; y <= logBottom; y++) {
90
+ const li = this.scrollTop + (y - logTop);
91
+ if (li >= lines.length)
92
+ break;
93
+ const line = lines[li];
94
+ buf.writeText(2, y, line.text.slice(0, w - 2), line.style);
95
+ }
96
+ buf.centerText(h - 1, "↑/↓ scroll · Esc back", { fg: C.dim });
97
+ }
98
+ }