bonecode 1.2.1 → 1.2.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.
package/src/stats.ts CHANGED
@@ -1,10 +1,49 @@
1
1
  /**
2
2
  * Token usage and cost statistics
3
3
  * Ported from opencode's cli/cmd/stats.ts — adapted for BoneCode's Postgres schema.
4
+ *
5
+ * Works on both Postgres (full schema with token columns on sessions) and
6
+ * SQLite fallback (sessions has only id/title/etc; token data lives on messages).
4
7
  */
5
8
 
6
9
  import { Pool } from "pg"
7
10
 
11
+ interface CostColumns {
12
+ cost: boolean
13
+ tokensInput: boolean
14
+ tokensOutput: boolean
15
+ tokensReasoning: boolean
16
+ tokensCacheRead: boolean
17
+ tokensCacheWrite: boolean
18
+ }
19
+
20
+ let _columnsCache: CostColumns | null = null
21
+
22
+ async function detectSessionCostColumns(pool: Pool): Promise<CostColumns> {
23
+ if (_columnsCache) return _columnsCache
24
+
25
+ const probe = async (col: string): Promise<boolean> => {
26
+ try {
27
+ // SELECT col FROM sessions LIMIT 0 — fails if column doesn't exist, succeeds otherwise.
28
+ // Works identically on Postgres and SQLite.
29
+ await pool.query(`SELECT ${col} FROM sessions LIMIT 0`)
30
+ return true
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ _columnsCache = {
37
+ cost: await probe("cost_usd"),
38
+ tokensInput: await probe("tokens_input"),
39
+ tokensOutput: await probe("tokens_output"),
40
+ tokensReasoning: await probe("tokens_reasoning"),
41
+ tokensCacheRead: await probe("tokens_cache_read"),
42
+ tokensCacheWrite: await probe("tokens_cache_write"),
43
+ }
44
+ return _columnsCache
45
+ }
46
+
8
47
  export interface SessionStats {
9
48
  totalSessions: number
10
49
  totalMessages: number
@@ -50,10 +89,23 @@ export async function aggregateStats(
50
89
  return opts.days
51
90
  })()
52
91
 
92
+ // Detect available columns by introspection — works on both Postgres and SQLite
93
+ const hasCol = await detectSessionCostColumns(pool)
94
+ const sessionSelect = [
95
+ "id",
96
+ "project_id",
97
+ "created_at",
98
+ "updated_at",
99
+ hasCol.cost ? "cost_usd" : "0 AS cost_usd",
100
+ hasCol.tokensInput ? "tokens_input" : "0 AS tokens_input",
101
+ hasCol.tokensOutput ? "tokens_output" : "0 AS tokens_output",
102
+ hasCol.tokensReasoning ? "tokens_reasoning" : "0 AS tokens_reasoning",
103
+ hasCol.tokensCacheRead ? "tokens_cache_read" : "0 AS tokens_cache_read",
104
+ hasCol.tokensCacheWrite ? "tokens_cache_write" : "0 AS tokens_cache_write",
105
+ ].join(", ")
106
+
53
107
  // Load sessions
54
- let sessionQuery = `SELECT id, project_id, cost_usd, tokens_input, tokens_output, tokens_reasoning,
55
- tokens_cache_read, tokens_cache_write, created_at, updated_at
56
- FROM sessions WHERE state != 'deleted'`
108
+ let sessionQuery = `SELECT ${sessionSelect} FROM sessions WHERE state != 'deleted'`
57
109
  const params: unknown[] = []
58
110
  let idx = 1
59
111
 
package/src/tui.ts CHANGED
@@ -393,19 +393,78 @@ interface ToolTrack {
393
393
  * "text" — pass deltas through verbatim (with partial-fence detection)
394
394
  * "fence" — buffer everything; emit marker on close fence
395
395
  */
396
+ /**
397
+ * Apply lightweight inline markdown styling to a streamed chunk.
398
+ *
399
+ * - `# `, `## `, `### ` at line-start become bold colored headers
400
+ * - `**text**` becomes bold
401
+ * - `` `code` `` becomes inverted gray
402
+ * - `- ` and `* ` at line-start become a bullet
403
+ *
404
+ * Operates per-line on the chunk. Cross-chunk markdown (a `**` opening in one
405
+ * delta and closing in another) is not handled — we just leave those raw.
406
+ * That's acceptable because it's rare and the model's output is still readable.
407
+ */
408
+ function applyInlineMarkdown(text: string): string {
409
+ if (!text) return text;
410
+ // Split on newlines so we can match line-anchored patterns (headers, bullets)
411
+ const lines = text.split("\n");
412
+ const styled = lines.map((line) => {
413
+ // Headers
414
+ let m = line.match(/^(#{1,6})\s+(.*)$/);
415
+ if (m) {
416
+ const level = m[1].length;
417
+ const content = m[2];
418
+ if (level === 1) return `${CYAN}${BOLD}${content}${R}`;
419
+ if (level === 2) return `${CYAN}${BOLD}${content}${R}`;
420
+ if (level === 3) return `${WHITE}${BOLD}${content}${R}`;
421
+ return `${WHITE}${content}${R}`;
422
+ }
423
+ // Bullets at line start
424
+ m = line.match(/^(\s*)[-*]\s+(.*)$/);
425
+ if (m) {
426
+ line = `${m[1]}${GRAY}•${R} ${m[2]}`;
427
+ }
428
+ // Numbered lists
429
+ m = line.match(/^(\s*)(\d+\.)\s+(.*)$/);
430
+ if (m) {
431
+ line = `${m[1]}${GRAY}${m[2]}${R} ${m[3]}`;
432
+ }
433
+ // Bold (**text**)
434
+ line = line.replace(/\*\*([^*]+)\*\*/g, `${BOLD}$1${R}`);
435
+ // Inline code (`code`)
436
+ line = line.replace(/`([^`]+)`/g, `${GRAY}$1${R}`);
437
+ return line;
438
+ });
439
+ return styled.join("\n");
440
+ }
441
+
442
+ /**
443
+ * Stateful code-fence collapser.
444
+ *
445
+ * Replaces ```lang ... ``` blocks with a one-line marker
446
+ * " ┃ code: lang, N lines" so streaming code blocks don't flood the TUI.
447
+ *
448
+ * State:
449
+ * "text" — pass through verbatim (with partial-fence buffering)
450
+ * "fence" — silently buffer body; emit final marker on close
451
+ *
452
+ * Newline handling:
453
+ * We strip up to one trailing newline before the opening fence and one
454
+ * leading newline after the closing fence so the marker sits on its own
455
+ * line without doubling blank lines around it.
456
+ */
396
457
  function makeCodeFenceCollapser() {
397
458
  let mode: "text" | "fence" = "text";
398
459
  let lang = "";
399
- let buffered = ""; // bytes accumulated inside a fence
400
- let pending = ""; // partial-fence buffer (when we see backticks but don't know yet)
401
-
402
- const lines = (s: string) => s.split("\n").length;
460
+ let buffered = "";
461
+ let pending = "";
403
462
 
404
- function flushPending(): string {
405
- const out = pending;
406
- pending = "";
407
- return out;
408
- }
463
+ const countLines = (s: string) => {
464
+ if (!s) return 0;
465
+ const trimmed = s.replace(/^\n+/, "").replace(/\n+$/, "");
466
+ return trimmed ? trimmed.split("\n").length : 0;
467
+ };
409
468
 
410
469
  return {
411
470
  feed(chunk: string): string {
@@ -416,23 +475,26 @@ function makeCodeFenceCollapser() {
416
475
 
417
476
  while (i < buf.length) {
418
477
  if (mode === "text") {
419
- // Look for ``` to enter fence mode
420
478
  const fenceIdx = buf.indexOf("```", i);
421
479
  if (fenceIdx === -1) {
422
- // No fence in remaining text emit it all, but hold the last 2 chars
423
- // in case they're part of an upcoming fence.
480
+ // No fence in remaining text. Hold last 2 chars in case they're
481
+ // the start of an upcoming fence.
424
482
  const safeEnd = Math.max(i, buf.length - 2);
425
483
  result += buf.slice(i, safeEnd);
426
484
  pending = buf.slice(safeEnd);
427
485
  i = buf.length;
428
486
  break;
429
487
  }
430
- // Emit text up to the fence
431
- result += buf.slice(i, fenceIdx);
432
- // Read the language (everything until newline)
488
+ // Emit text up to the fence, but trim a single trailing newline so
489
+ // the marker sits on its own line without extra blank space.
490
+ let preFence = buf.slice(i, fenceIdx);
491
+ if (preFence.endsWith("\n")) preFence = preFence.slice(0, -1);
492
+ result += preFence;
493
+
494
+ // Read the language declaration (everything up to the next newline).
433
495
  const nlIdx = buf.indexOf("\n", fenceIdx + 3);
434
496
  if (nlIdx === -1) {
435
- // Don't have the full opening line yet — buffer
497
+ // Opening line not complete yet — buffer for next feed.
436
498
  pending = buf.slice(fenceIdx);
437
499
  i = buf.length;
438
500
  break;
@@ -441,31 +503,36 @@ function makeCodeFenceCollapser() {
441
503
  i = nlIdx + 1;
442
504
  mode = "fence";
443
505
  buffered = "";
444
- // Print a leading marker (will be amended when we close)
445
- result += `${GRAY}┃ code: ${lang}…${R}`;
506
+ // Don't emit anything yet — the final marker is written when we
507
+ // see the closing fence so we have the line count.
446
508
  continue;
447
509
  }
448
510
 
449
511
  // mode === "fence" — look for closing ```
450
512
  const closeIdx = buf.indexOf("```", i);
451
513
  if (closeIdx === -1) {
514
+ // No close fence yet — buffer the body.
452
515
  buffered += buf.slice(i);
453
- // Hold last 2 chars in case they're part of an upcoming close fence
516
+ // Hold last 2 chars in case they're part of an upcoming close fence.
454
517
  const safeEnd = Math.max(i, buf.length - 2);
455
518
  buffered = buffered.slice(0, buffered.length - (buf.length - safeEnd));
456
519
  pending = buf.slice(safeEnd);
457
520
  i = buf.length;
458
521
  break;
459
522
  }
460
- // Closing fence found
523
+
524
+ // Closing fence found.
461
525
  buffered += buf.slice(i, closeIdx);
462
- const lineCount = lines(buffered.replace(/\n+$/, ""));
463
- // Replace the placeholder we already wrote with the final marker.
464
- // Carriage return + clear line + reprint marker.
465
- result += `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount} line${lineCount === 1 ? "" : "s"}${R}\n`;
526
+ const lineCount = countLines(buffered);
527
+ // Emit the marker on its own line with leading and trailing newlines.
528
+ // The outer indenter will prepend " " to each line break.
529
+ result += `\n${GRAY}┃ code: ${lang}, ${lineCount} line${lineCount === 1 ? "" : "s"}${R}`;
466
530
  i = closeIdx + 3;
467
- // Skip optional newline after closing fence
531
+ // Skip a single newline immediately after the close fence to avoid
532
+ // doubling blank lines.
468
533
  if (buf[i] === "\n") i++;
534
+ // Add a trailing newline so the next text starts on a fresh line.
535
+ result += "\n";
469
536
  mode = "text";
470
537
  lang = "";
471
538
  buffered = "";
@@ -474,12 +541,12 @@ function makeCodeFenceCollapser() {
474
541
  return result;
475
542
  },
476
543
  flush(): string {
477
- const tail = flushPending();
544
+ const tail = pending;
545
+ pending = "";
478
546
  if (mode === "fence") {
479
- // Stream ended mid-fence — close it with an approximate count
480
- const lineCount = lines(buffered.replace(/\n+$/, ""));
547
+ const lineCount = countLines(buffered);
481
548
  mode = "text";
482
- return tail + `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount}+ lines${R}\n`;
549
+ return tail + `\n${GRAY}┃ code: ${lang}, ${lineCount}+ lines${R}\n`;
483
550
  }
484
551
  return tail;
485
552
  },
@@ -563,8 +630,10 @@ async function streamPrompt(opts: {
563
630
  // code blocks appear as "[code: lang]" placeholders instead of
564
631
  // dumping raw source.
565
632
  const piece = collapseCodeFences(text);
633
+ // Apply lightweight markdown styling (headers, bold, inline code)
634
+ const styled = applyInlineMarkdown(piece);
566
635
  // Print with leading-newline indenting (so each new line gets the 3-space prefix)
567
- const indented = piece.replace(/\n/g, `\n `);
636
+ const indented = styled.replace(/\n/g, `\n `);
568
637
  out(indented);
569
638
  fullText += text;
570
639
  continue;
@@ -727,6 +796,22 @@ export async function runTUI(opts: {
727
796
  port: number; token: string; model: string;
728
797
  provider: string; worktree: string; sessionId?: string;
729
798
  }): Promise<void> {
799
+ // Force UTF-8 output on Windows so emoji and box-drawing chars render
800
+ // correctly. Without this, console code page 437/850 corrupts multi-byte
801
+ // sequences into "??" or mojibake.
802
+ if (process.platform === "win32") {
803
+ try {
804
+ // Set output encoding for stdout/stderr
805
+ (process.stdout as any).setEncoding?.("utf-8");
806
+ (process.stderr as any).setEncoding?.("utf-8");
807
+ // Switch the console to UTF-8 (code page 65001)
808
+ const { execSync } = require("child_process");
809
+ execSync("chcp 65001", { stdio: "ignore" });
810
+ } catch {
811
+ // chcp not available — that's fine, we tried
812
+ }
813
+ }
814
+
730
815
  let { model, provider } = opts;
731
816
  const { port, token, worktree } = opts;
732
817
  let sessionId = opts.sessionId || null;