@krmxd/onegpt 0.2.0-beta → 0.2.2-beta

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +25 -8
  3. package/src/render.js +151 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krmxd/onegpt",
3
- "version": "0.2.0-beta",
3
+ "version": "0.2.2-beta",
4
4
  "description": "OGPT - AI coding assistant for the terminal with a built-in local engine",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -131,7 +131,15 @@ const EXECUTE_NUDGE =
131
131
 
132
132
  function wantsAction(userInput) {
133
133
  // True when the user asked for something to be built (not explained).
134
- return TASK_RE.test(userInput) && !QUESTION_RE.test(userInput.trim());
134
+ const t = String(userInput || "").trim();
135
+ const tm = TASK_RE.exec(t);
136
+ if (!tm) return false;
137
+ // A question word BEFORE the build verb means they're ASKING, not
138
+ // requesting work: "how do I fix the login bug?" must be answered,
139
+ // not turned into a flurry of file edits.
140
+ const qm = /\b(how|what|why|when|where|which|who|whom|explain|describe)\b/i.exec(t);
141
+ if (qm && qm.index < tm.index) return false;
142
+ return true;
135
143
  }
136
144
 
137
145
  function redirectMsg(task) {
@@ -465,11 +473,14 @@ function diffLines(oldStr, newStr) {
465
473
  }
466
474
 
467
475
  function codeMarkdown(ev) {
476
+ // The fence info carries the file so renderers can show a titled,
477
+ // macOS-style code box: ```lang path=folder/file.ext
478
+ const rel = String(ev.path || "").replace(/^\/+/, "");
468
479
  if (ev.action === "EDITED" && ev.old_content !== undefined) {
469
- return "```diff\n" + diffLines(ev.old_content, ev.content).join("\n") + "\n```";
480
+ return "```diff path=" + rel + "\n" + diffLines(ev.old_content, ev.content).join("\n") + "\n```";
470
481
  }
471
482
  const lang = detectLang(ev.path);
472
- return "```" + lang + "\n" + ev.content + "\n```";
483
+ return "```" + lang + " path=" + rel + "\n" + ev.content + "\n```";
473
484
  }
474
485
 
475
486
  class Agent {
@@ -631,8 +642,11 @@ class Agent {
631
642
  // Self-correction: instructions/code instead of done work gets one
632
643
  // firm push to act - on ANY round, not just the first (tiny models
633
644
  // relapse into how-to mode mid-task). An EMPTY reply after partial
634
- // work means it stalled - push it too.
635
- const replyLazy = repliedWithoutActing(resp.content);
645
+ // work means it stalled - push it too. BUT a reply is never "lazy"
646
+ // once real work happened this turn: closing summaries often quote
647
+ // commands in fences ("run it with ```npm start```") - nudging then
648
+ // made the model edit files over and over instead of answering.
649
+ const replyLazy = repliedWithoutActing(resp.content) && doneBits.length === 0;
636
650
  const wentSilent = !resp.content.trim() && (rounds > 1 || doneBits.length > 0);
637
651
  if (nudges < 3 && !this._cancelled
638
652
  && wantsAction(userInput) && (replyLazy || wentSilent || stalledHalfway(resp.content, userInput, doneBits))) {
@@ -784,8 +798,11 @@ class Agent {
784
798
  // Self-correction: instructions/code instead of done work gets one
785
799
  // firm push to act - on ANY round, not just the first (tiny models
786
800
  // relapse into how-to mode mid-task). An EMPTY reply after partial
787
- // work means it stalled - push it too.
788
- const replyLazy = repliedWithoutActing(full);
801
+ // work means it stalled - push it too. BUT a reply is never "lazy"
802
+ // once real work happened this turn: closing summaries often quote
803
+ // commands in fences ("run it with ```npm start```") - nudging then
804
+ // made the model edit files over and over instead of answering.
805
+ const replyLazy = repliedWithoutActing(full) && doneBits.length === 0;
789
806
  const wentSilent = !full.trim() && (rounds > 1 || doneBits.length > 0);
790
807
  if (nudges < 3 && !this._cancelled
791
808
  && wantsAction(userInput) && (replyLazy || wentSilent || stalledHalfway(full, userInput, doneBits))) {
@@ -892,4 +909,4 @@ class Agent {
892
909
  }
893
910
  }
894
911
 
895
- module.exports = { Agent, extractJsonTools, repairToolJson };
912
+ module.exports = { Agent, extractJsonTools, repairToolJson, wantsAction, repliedWithoutActing, stalledHalfway };
package/src/render.js CHANGED
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
 
3
3
  // Terminal chat renderer, opencode-style:
4
- // - live-updating output (SmoothPrinter repaints in place instead of
5
- // spilling raw tokens, which is what made streaming feel choppy)
6
- // - fenced code blocks drawn as rounded boxes with a language label
4
+ // - live-updating output (SmoothPrinter repaints in place; every line is
5
+ // hard-truncated with WIDE-CHAR-AWARE measuring so the terminal never
6
+ // soft-wraps behind our back - that desync used to duplicate text)
7
+ // - fenced code blocks drawn as rounded boxes with macOS-style
8
+ // traffic-light circles and the FILE TITLE when known
7
9
  // - light inline styling: bold/italic, `inline code`, headings, links
8
- // Everything degrades to plain passthrough when stdout is not a TTY.
10
+ // Everything degrades gracefully: non-TTY or a mid-stream resize falls
11
+ // back to plain passthrough instead of ever duplicating output.
9
12
 
10
13
  const ESC = "\x1b";
11
14
  const C = {
@@ -16,23 +19,48 @@ const C = {
16
19
 
17
20
  function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ""); }
18
21
 
22
+ const WIDE_RANGES = [
23
+ [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff],
24
+ [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
25
+ [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe6f],
26
+ [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
27
+ [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
28
+ ];
29
+
30
+ function charWidth(cp) {
31
+ if (cp === 0) return 0;
32
+ for (const [lo, hi] of WIDE_RANGES) {
33
+ if (cp >= lo && cp <= hi) return 2;
34
+ }
35
+ return cp > 0xffff ? 2 : 1;
36
+ }
37
+
38
+ // Display width of a string as the terminal will draw it (ANSI skipped).
39
+ function visibleWidth(s) {
40
+ let w = 0;
41
+ const plain = stripAnsi(s);
42
+ for (const ch of plain) w += charWidth(ch.codePointAt(0));
43
+ return w;
44
+ }
45
+
19
46
  function truncateVisible(s, max) {
20
47
  if (visibleWidth(s) <= max) return s;
21
48
  let out = "", w = 0;
22
49
  for (let i = 0; i < s.length; i++) {
23
50
  if (s[i] === ESC && s[i + 1] === "[") {
24
- const m = /\x1b\[[0-9;]*m/.exec(s.slice(i));
51
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
25
52
  if (m) { out += m[0]; i += m[0].length - 1; continue; }
26
53
  }
27
- w += s.codePointAt(i) > 0xffff ? 2 : 1;
28
- if (w > max - 1) return out + C.dim + "…" + C.reset;
29
- out += s[i];
54
+ const cp = s.codePointAt(i);
55
+ const cw = charWidth(cp);
56
+ if (w + cw > max - 1) return out + C.dim + "…" + C.reset;
57
+ w += cw;
58
+ out += String.fromCodePoint(cp);
59
+ if (cp > 0xffff) i++;
30
60
  }
31
61
  return out;
32
62
  }
33
63
 
34
- function visibleWidth(s) { return stripAnsi(s).length; }
35
-
36
64
  const KEYWORDS = new Set(("const let var function return if else for while class import " +
37
65
  "from export default async await new try catch throw switch case break continue " +
38
66
  "def lambda elif None True False self public private static void int float str " +
@@ -63,15 +91,64 @@ function padVisible(s, width) {
63
91
  return s + " ".repeat(Math.max(0, pad));
64
92
  }
65
93
 
66
- function renderCodeBox(lang, lines, width) {
94
+ const EXT_LANG = {
95
+ html: "html", htm: "html", css: "css", js: "javascript", mjs: "javascript",
96
+ cjs: "javascript", jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
97
+ json: "json", md: "markdown", sh: "bash", bash: "bash", yml: "yaml",
98
+ yaml: "yaml", txt: "text", csv: "text", go: "go", rs: "rust", java: "java",
99
+ c: "c", h: "c", cpp: "cpp", cs: "csharp", rb: "ruby", php: "php",
100
+ sql: "sql", kt: "kotlin", swift: "swift", dart: "dart", lua: "lua",
101
+ };
102
+
103
+ function langFromFilename(name) {
104
+ const ext = (/\.([a-z0-9]+)$/i.exec(name) || [])[1];
105
+ return ext ? (EXT_LANG[ext.toLowerCase()] || ext.toLowerCase()) : "";
106
+ }
107
+
108
+ /**
109
+ * Fence info strings may carry the file title:
110
+ * ```js | ```js path=src/app.py | ```index.html
111
+ * Returns {lang, title}.
112
+ */
113
+ function parseFenceInfo(info) {
114
+ const tokens = String(info || "").trim().split(/\s+/).filter(Boolean);
115
+ let lang = "", title = "";
116
+ for (const tok of tokens) {
117
+ const m = /^(?:path|file)=(.+)$/.exec(tok);
118
+ if (m) { title = m[1]; continue; }
119
+ if (!lang) lang = tok;
120
+ }
121
+ if (title) {
122
+ // A bare filename as the language slot also means title.
123
+ if (!lang && /\.[a-z0-9]+$/i.test(tokens[0] || "")) lang = "";
124
+ lang = lang || langFromFilename(title);
125
+ } else if (lang && /\.[a-z0-9]{1,8}$/i.test(lang) && !lang.includes("(")) {
126
+ // ```index.html style - treat as a filename
127
+ title = lang;
128
+ lang = langFromFilename(lang);
129
+ }
130
+ return { lang, title };
131
+ }
132
+
133
+ function renderCodeBox(info, lines, width) {
67
134
  const inner = Math.max(20, width - 4);
68
- const label = (lang || "").trim();
69
- const top = label
70
- ? `╭─ ${C.cyan}${label}${C.reset} ${"─".repeat(Math.max(1, inner - label.length - 4))}`
71
- : `╭─${"─".repeat(inner - 1)}`;
135
+ const { lang, title } = typeof info === "string" ? parseFenceInfo(info) : info;
136
+ // macOS window chrome: three traffic lights, then title (file name when
137
+ // known, otherwise the language tag).
138
+ const lights = `${C.red}●${C.reset} ${C.yellow}●${C.reset} ${C.green}●${C.reset}`;
139
+ const label = title
140
+ ? `${lights} ${C.dim}─${C.reset} ${C.bold}${C.cyan}${truncateVisible(title, Math.max(6, inner - 12))}${C.reset}`
141
+ : (lang
142
+ ? `${lights} ${C.dim}─${C.reset} ${C.bold}${lang}${C.reset}`
143
+ : lights);
144
+ const used = visibleWidth(label);
145
+ // Top border must fit within `width` like every other line:
146
+ // "╭─ " (3) + label + " " (1) + dash <= width
147
+ const dash = C.dim + "─".repeat(Math.max(1, width - used - 5)) + C.reset;
148
+ const top = truncateVisible(`╭─ ${label} ${dash}`, width);
72
149
  const body = lines.map((l) =>
73
150
  `${C.dim}│${C.reset} ` + padVisible(truncateVisible(tintCode(l), inner), inner) + ` ${C.dim}│${C.reset}`);
74
- return [top + C.dim + "╮" + C.reset, ...body,
151
+ return [top, ...body,
75
152
  C.dim + "╰" + "─".repeat(inner + 2) + "╯" + C.reset];
76
153
  }
77
154
 
@@ -86,35 +163,35 @@ function renderInline(s) {
86
163
  /**
87
164
  * Render a full assistant message for the terminal.
88
165
  * Fenced blocks become rounded boxes; prose gets light markdown styling.
89
- * Lines are hard-truncated to the given width so repaint math stays exact.
166
+ * EVERY emitted line is guaranteed to fit within `width` display cells,
167
+ * so the terminal never soft-wraps and repaint math stays exact.
90
168
  */
91
169
  function renderMarkdown(text, width = 100) {
92
- width = Math.max(40, Math.min(120, width));
170
+ width = Math.max(40, Math.min(200, width));
93
171
  const src = String(text ?? "").split("\n");
94
172
  const out = [];
95
- let inFence = false, lang = "", buf = [];
173
+ let inFence = false, info = "", buf = [];
96
174
  const flushFence = () => {
97
- out.push(...renderCodeBox(lang, buf, width));
98
- buf = []; lang = "";
175
+ out.push(...renderCodeBox(info, buf, width));
176
+ buf = []; info = "";
99
177
  };
100
178
  for (const line of src) {
101
179
  const m = /^\s*```(.*)$/.exec(line);
102
180
  if (m) {
103
181
  if (inFence) { flushFence(); inFence = false; }
104
- else { inFence = true; lang = m[1]; }
182
+ else { inFence = true; info = m[1]; }
105
183
  continue;
106
184
  }
107
185
  if (inFence) { buf.push(line); continue; }
108
186
  const h = /^(#{1,6})\s+(.*)$/.exec(line);
109
187
  if (h) {
110
- out.push(C.bold + C.cyan + h[2] + C.reset);
188
+ out.push(truncateVisible(C.bold + C.cyan + h[2] + C.reset, width));
111
189
  continue;
112
190
  }
113
191
  if (/^\s*(-{3,}|={3,}|\*{3,})\s*$/.test(line)) {
114
- out.push(C.dim + "─".repeat(width) + C.reset);
192
+ out.push(truncateVisible(C.dim + "─".repeat(width) + C.reset, width));
115
193
  continue;
116
194
  }
117
- if (/^\s*&gt;\s?/.test(line)) { out.push(C.dim + line + C.reset); continue; }
118
195
  out.push(truncateVisible(renderInline(line), width));
119
196
  }
120
197
  if (inFence) flushFence();
@@ -124,15 +201,17 @@ function renderMarkdown(text, width = 100) {
124
201
  const MAX_PREVIEW_CHARS = 60000;
125
202
 
126
203
  /**
127
- * In-place repainting stream target. TTY: buffers chunks and repaints the
128
- * rendered message every ~50ms (opencode feel). Non-TTY: raw passthrough.
204
+ * In-place repainting stream target.
205
+ * TTY: buffers chunks and repaints the rendered message every ~60ms.
206
+ * Non-TTY / resized mid-stream / absurdly tall paints: raw passthrough,
207
+ * because a WRONG erase duplicates text ("spamming") - never risk it.
129
208
  */
130
209
  class SmoothPrinter {
131
210
  constructor(out = process.stdout) {
132
211
  this.out = out;
133
212
  this.tty = !!(out.isTTY && process.env.TERM !== "dumb"
134
213
  && !process.env.OGPT_PLAIN);
135
- this.width = Math.max(40, Math.min(120, (out.columns || 100) - 2));
214
+ this.widthOf = () => Math.max(40, Math.min(200, (out.columns || 100) - 2));
136
215
  }
137
216
 
138
217
  begin() {
@@ -140,19 +219,21 @@ class SmoothPrinter {
140
219
  this.drawn = 0;
141
220
  this.lastPaint = 0;
142
221
  this.timer = null;
222
+ this.closed = false;
223
+ this.degraded = !this.tty;
224
+ this.paintWidth = null;
143
225
  if (!this.tty) return;
144
- // Hide the cursor while we repaint to avoid flicker.
145
226
  this.out.write(`${ESC}[?25l`);
146
227
  }
147
228
 
148
229
  feed(chunk) {
149
- if (!this.tty) { this.out.write(chunk); return; }
230
+ if (this.degraded) { this.out.write(chunk); return; }
150
231
  this.buf += chunk;
151
232
  if (this.buf.length > MAX_PREVIEW_CHARS * 1.5) {
152
233
  this.buf = this.buf.slice(-MAX_PREVIEW_CHARS);
153
234
  }
154
235
  if (!this.timer) {
155
- this.timer = setTimeout(() => { this.timer = null; this._paint(); }, 50);
236
+ this.timer = setTimeout(() => { this.timer = null; this._paint(); }, 60);
156
237
  }
157
238
  }
158
239
 
@@ -162,25 +243,55 @@ class SmoothPrinter {
162
243
  }
163
244
 
164
245
  _paint() {
165
- if (!this.tty || this.closed) return;
246
+ if (this.closed || this.degraded) return;
166
247
  const now = Date.now();
167
- if (now - this.lastPaint < 45) return;
168
- this._erase();
248
+ if (now - this.lastPaint < 50) return;
249
+ const cols = this.out.columns;
250
+ // Terminal resized since the last paint: old rows were wrapped at a
251
+ // different width, so erasing would leave ghosts -> degrade safely.
252
+ if (this.paintWidth !== null && cols && cols !== this.paintWidth) {
253
+ this._degrade();
254
+ return;
255
+ }
256
+ const width = this.widthOf();
169
257
  let preview = this.buf;
170
258
  if (preview.length > MAX_PREVIEW_CHARS) preview = preview.slice(-MAX_PREVIEW_CHARS);
171
- const rendered = renderMarkdown(preview, this.width);
259
+ const rendered = renderMarkdown(preview, width);
260
+ const lines = rendered.split("\n");
261
+ const rows = Math.max(0, lines.length - 1);
262
+ const maxRows = (this.out.rows || 2000) - 1;
263
+ if (rows > maxRows) {
264
+ // Taller than the screen: scrolling would already have eaten the top
265
+ // rows - repainting can't reach them. Degrade instead of duplicating.
266
+ this._degrade(true);
267
+ return;
268
+ }
269
+ this._erase();
172
270
  this.out.write(rendered);
173
- this.drawn = rendered.split("\n").length - 1;
271
+ this.drawn = rows;
272
+ this.paintWidth = cols || width;
174
273
  this.lastPaint = now;
175
274
  }
176
275
 
276
+ _degrade(skipFlush) {
277
+ this.degraded = true;
278
+ if (!skipFlush && this.buf) {
279
+ // Hand back the un-styled tail so nothing is lost visually.
280
+ this.out.write("\n" + this.buf.slice(-4000) + "\n");
281
+ } else {
282
+ this.out.write("\n");
283
+ }
284
+ this.buf = "";
285
+ }
286
+
177
287
  finish() {
178
288
  if (this.closed) return;
179
289
  this.closed = true;
180
290
  if (this.timer) { clearTimeout(this.timer); this.timer = null; }
181
- if (!this.tty) return;
291
+ if (!this.tty || this.degraded) return;
182
292
  this._erase();
183
- this.out.write(renderMarkdown(this.buf, this.width) + "\n");
293
+ const width = this.widthOf();
294
+ this.out.write(renderMarkdown(this.buf, width) + "\n");
184
295
  this.out.write(`${ESC}[?25h`);
185
296
  }
186
297
 
@@ -193,4 +304,5 @@ class SmoothPrinter {
193
304
  }
194
305
  }
195
306
 
196
- module.exports = { renderMarkdown, SmoothPrinter, tintCode, visibleWidth };
307
+ module.exports = { renderMarkdown, SmoothPrinter, tintCode, visibleWidth,
308
+ parseFenceInfo, truncateVisible };