kob-cli 1.0.24 → 1.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kob-cli",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "KOB CLI — AI-powered code generation tool. Built by Kob AI, made in Thailand.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,6 +62,58 @@ export function kobImagesDir(): string {
62
62
  return dir;
63
63
  }
64
64
 
65
+ export function kobHistoryDir(): string {
66
+ const dir = join(homedir(), '.kob-cli', 'history');
67
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
68
+ return dir;
69
+ }
70
+
71
+ export function getHistoryFilePath(): string {
72
+ const cwd = process.cwd();
73
+ const slug = cwd.replace(/[\\/:\s]+/g, '_').slice(0, 120);
74
+ return join(kobHistoryDir(), `${slug}.json`);
75
+ }
76
+
77
+ export function loadChatHistory(): {
78
+ exchanges: Exchange[];
79
+ messages: { role: string; content: string }[];
80
+ } {
81
+ const path = getHistoryFilePath();
82
+ if (!existsSync(path)) return { exchanges: [], messages: [] };
83
+ try {
84
+ const raw = readFileSync(path, 'utf-8');
85
+ const data = JSON.parse(raw);
86
+ if (data.exchanges && Array.isArray(data.exchanges)) {
87
+ return {
88
+ exchanges: data.exchanges,
89
+ messages: data.messages || [],
90
+ };
91
+ }
92
+ } catch { /* ignore corrupted history */ }
93
+ return { exchanges: [], messages: [] };
94
+ }
95
+
96
+ export function saveChatHistory(
97
+ exchanges: Exchange[],
98
+ messages: { role: string; content: string }[]
99
+ ): void {
100
+ const path = getHistoryFilePath();
101
+ try {
102
+ writeFileSync(
103
+ path,
104
+ JSON.stringify({ exchanges, messages, savedAt: Date.now() }, null, 2),
105
+ 'utf-8'
106
+ );
107
+ } catch { /* ignore write errors */ }
108
+ }
109
+
110
+ export function clearChatHistory(): void {
111
+ const path = getHistoryFilePath();
112
+ try {
113
+ if (existsSync(path)) writeFileSync(path, '{}', 'utf-8');
114
+ } catch { /* ignore write errors */ }
115
+ }
116
+
65
117
  // Save a local file path into our images dir and return the absolute path.
66
118
  export function attachImagePath(srcPath: string): string {
67
119
  const abs = resolve(srcPath);
@@ -455,92 +507,80 @@ function BrandHeader({
455
507
  }) {
456
508
  const { frame } = useAnimation({ interval: 600 });
457
509
  const isThinking = phase === 'generating';
458
- const dotColor = isThinking ? c.yellow : c.green;
459
- const statusText = isThinking ? 'thinking' : 'ready';
460
- const statusIcon = isThinking ? '' : '✓';
510
+ const dotColor = c.green;
511
+ const statusText = 'ready';
512
+ const statusIcon = '';
461
513
 
462
514
  const K = c.brand; // cyan for KOB
463
- const C = c.pink; // pink for CLI
515
+ const C = c.green; // green for CLI
464
516
 
465
517
  return (
466
- <Box flexDirection="column">
518
+ <Box flexDirection="column" marginTop={2}>
467
519
  {/* Big ASCII KOB CLI logo */}
468
520
  <Box flexDirection="column" alignItems="center">
469
521
  <Box>
470
- <Text color={K}>██╗ ██╗ ██████╗ ██████╗ </Text>
522
+ <Text color={c.blue}>██╗ ██╗ ██████╗ ██████╗ </Text>
471
523
  <Text color={C}>██████╗██╗ ██╗</Text>
472
524
  </Box>
473
525
  <Box>
474
- <Text color={K}>██║ ██╔╝██╔═══██╗██╔══██╗ </Text>
526
+ <Text color={c.blue}>██║ ██╔╝██╔═══██╗██╔══██╗ </Text>
475
527
  <Text color={C}>██╔════╝██║ ██║</Text>
476
528
  </Box>
477
529
  <Box>
478
- <Text color={K}>█████╔╝ ██║ ██║██████╔╝ </Text>
530
+ <Text color="white">█████╔╝ ██║ ██║██████╔╝ </Text>
479
531
  <Text color={C}>██║ ██║ ██║</Text>
480
532
  </Box>
481
533
  <Box>
482
- <Text color={K}>██╔═██╗ ██║ ██║██╔══██╗ </Text>
534
+ <Text color="white">██╔═██╗ ██║ ██║██╔══██╗ </Text>
483
535
  <Text color={C}>██║ ██║ ██║</Text>
484
536
  </Box>
485
537
  <Box>
486
- <Text color={K}>██║ ██╗╚██████╔╝██████╔╝ </Text>
538
+ <Text color={c.red}>██║ ██╗╚██████╔╝██████╔╝ </Text>
487
539
  <Text color={C}>╚██████╗███████╗██║</Text>
488
540
  </Box>
489
541
  <Box>
490
- <Text color={K}>╚═╝ ╚═╝ ╚═════╝ ╚═════╝ </Text>
542
+ <Text color={c.red}>╚═╝ ╚═╝ ╚═════╝ ╚═════╝ </Text>
491
543
  <Text color={C}>╚═════╝╚══════╝╚═╝</Text>
492
544
  </Box>
493
545
  </Box>
494
546
 
547
+ {/* Tagline */}
548
+ <Box justifyContent="center" marginTop={1}>
549
+ <Text color={c.textMuted} italic>Made in Thailand</Text>
550
+ </Box>
551
+
495
552
  {/* Info bar with brand identity + live status */}
496
553
  <Box
497
- borderStyle="round"
554
+ borderStyle="single"
555
+ borderTop={false}
556
+ borderLeft={false}
557
+ borderRight={false}
498
558
  borderColor={c.brand}
499
559
  paddingX={1}
500
560
  flexDirection="column"
501
561
  marginTop={1}
502
562
  >
503
- <Box>
504
- <Text color={c.text} bold>KOB CLI</Text>
505
- <Text color={c.textDim}> </Text>
506
- <Text color={c.pink} italic>Thailand</Text>
507
- <Text> </Text>
508
- <Text backgroundColor="red" color="red">▰▰</Text>
509
- <Text> </Text>
510
- <Text color="white" bold>▰</Text>
511
- <Text> </Text>
512
- <Text backgroundColor="blue" color="blue">▰▰</Text>
513
- <Text color={c.textDim}> · </Text>
514
- <Text color={c.brand} bold>powered by</Text>
515
- <Text> </Text>
516
- <Text color={c.green} bold>Tavon Seesenpila</Text>
517
- <Text color={c.textDim}> </Text>
518
- <Text color={c.textMuted} italic>Founder of Kob AI</Text>
519
- </Box>
520
-
521
- {/* Row 2: version + model info */}
522
563
  <Box>
523
564
  <Box>
524
- <Text color={c.textDim}>v{version} · AI Command-Line Interface</Text>
565
+ <Text color={c.text} bold>KOB CLI</Text>
566
+ <Text color={c.textDim}> · </Text>
567
+ <Text color={c.green} bold>Tavon Seesenpila</Text>
568
+ <Text color={c.textDim}> </Text>
569
+ <Text color={c.textMuted} italic>Founder of Kob AI</Text>
525
570
  </Box>
526
571
  <Box flexGrow={1} />
572
+ <Box>
573
+ <Text color={c.textDim}>v{version}</Text>
574
+ </Box>
575
+ </Box>
576
+
577
+ {/* Row 2: model info */}
578
+ <Box>
527
579
  <Box>
528
580
  <Text color={c.textDim}>model </Text>
529
581
  <Text color={c.pink} bold>{modelName}</Text>
530
582
  <Text color={c.textDim}> · </Text>
531
583
  <Text color={c.text}>{provider}</Text>
532
- <Text color={c.textDim}> · </Text>
533
- <Text color={c.textMuted}>/v2/chat</Text>
534
- <Text color={c.textDim}> · </Text>
535
- <Text color={c.green}>bearer</Text>
536
- <Text color={c.textDim}> · </Text>
537
- {modelSupportsVision(modelName) ? (
538
- <Text color={c.accent} bold>👁 vision</Text>
539
- ) : (
540
- <Text color={c.borderDim}>◌ text-only</Text>
541
- )}
542
- <Text color={c.textDim}> · </Text>
543
- <Text color={c.green}>stream ✓</Text>
544
584
  </Box>
545
585
  </Box>
546
586
 
@@ -566,7 +606,7 @@ function BrandHeader({
566
606
  <Text color={c.textDim}> </Text>
567
607
  <Text color={c.pink}>↑ {formatNum(session.totalOut)}</Text>
568
608
  <Text color={c.textDim}> · </Text>
569
- <Text color={dotColor}>{statusIcon} {statusText}</Text>
609
+ <Text color={dotColor}>{statusIcon}</Text>
570
610
  </Box>
571
611
  </Box>
572
612
  </Box>
@@ -608,6 +648,7 @@ type LineItem =
608
648
  | { kind: 'response-line'; text: string; modeColor: string; isFirst: boolean }
609
649
  | { kind: 'response-trunc'; totalChars: number; totalLines: number }
610
650
  | { kind: 'no-response' }
651
+ | { kind: 'code-block'; lang: string; lines: string[] }
611
652
  | { kind: 'file-line'; filename: string; add: number; del: number; created: boolean }
612
653
  | { kind: 'cmd-header'; cmd: string; ok: boolean; statusColor: string; exitCode: number; durationMs: number }
613
654
  | { kind: 'cmd-output'; text: string; statusColor: string }
@@ -641,14 +682,46 @@ function buildLineBuffer(exchanges: Exchange[]): LineItem[] {
641
682
  const total = respLines.length;
642
683
  const trunc = total > MAX_RESP_LINES;
643
684
  const shown = trunc ? respLines.slice(0, MAX_RESP_LINES) : respLines;
685
+
686
+ let inCode = false;
687
+ let codeBuf: string[] = [];
688
+ let codeLang = '';
689
+
690
+ const flushCode = () => {
691
+ if (codeBuf.length > 0) {
692
+ buf.push({ kind: 'code-block', lang: codeLang, lines: codeBuf });
693
+ codeBuf = [];
694
+ codeLang = '';
695
+ }
696
+ };
697
+
644
698
  shown.forEach((line, j) => {
645
- buf.push({
646
- kind: 'response-line',
647
- text: line,
648
- modeColor: m.color,
649
- isFirst: j === 0,
650
- });
699
+ const trimmed = line.trim();
700
+ if (trimmed.startsWith('```')) {
701
+ if (inCode) {
702
+ flushCode();
703
+ inCode = false;
704
+ } else {
705
+ flushCode(); // flush any prose before code block
706
+ inCode = true;
707
+ codeLang = trimmed.slice(3).trim();
708
+ }
709
+ return;
710
+ }
711
+ if (inCode) {
712
+ codeBuf.push(line);
713
+ } else {
714
+ buf.push({
715
+ kind: 'response-line',
716
+ text: line,
717
+ modeColor: m.color,
718
+ isFirst: j === 0,
719
+ });
720
+ }
651
721
  });
722
+
723
+ if (inCode) flushCode(); // response ended inside code block
724
+
652
725
  if (trunc) {
653
726
  buf.push({ kind: 'response-trunc', totalChars: exc.output.length, totalLines: total });
654
727
  }
@@ -745,6 +818,28 @@ function renderLine(item: LineItem, idx: number): React.ReactNode {
745
818
  <Text color={c.red}>✗ (no response received)</Text>
746
819
  </Box>
747
820
  );
821
+ case 'code-block':
822
+ return (
823
+ <Box key={key} marginLeft={2} marginTop={1} marginBottom={1}>
824
+ <Box
825
+ borderStyle="round"
826
+ borderColor={c.borderDim}
827
+ backgroundColor={c.panel}
828
+ paddingX={1}
829
+ paddingY={1}
830
+ flexDirection="column"
831
+ >
832
+ <Box>
833
+ <Text color={c.accent}>◆ {item.lang || 'code'}</Text>
834
+ </Box>
835
+ {item.lines.map((line, i) => (
836
+ <Box key={i}>
837
+ <Text color={c.green}>{line.length === 0 ? ' ' : line}</Text>
838
+ </Box>
839
+ ))}
840
+ </Box>
841
+ </Box>
842
+ );
748
843
  case 'file-line':
749
844
  return (
750
845
  <Box key={key} marginLeft={2}>
@@ -788,9 +883,13 @@ function renderLine(item: LineItem, idx: number): React.ReactNode {
788
883
  return (
789
884
  <Box key={key} marginLeft={2} marginTop={1}>
790
885
  <Text color={c.textDim}>↳ </Text>
791
- <Text color={c.textMuted}>{item.exc.output.length} chars</Text>
886
+ <Text color={c.textMuted}>{item.exc.output.length} </Text>
887
+ <Text color={c.yellow}>chars</Text>
792
888
  <Text color={c.textDim}> · </Text>
793
- <Text color={c.textMuted}>↓ {formatNum(item.exc.inTokens)} ↑ {formatNum(item.exc.outTokens)} tok</Text>
889
+ <Text color={c.blue}>↓ {formatNum(item.exc.inTokens)}</Text>
890
+ <Text color={c.textDim}> </Text>
891
+ <Text color={c.pink}>↑ {formatNum(item.exc.outTokens)} </Text>
892
+ <Text color={c.yellow}>tok</Text>
794
893
  <Text color={c.textDim}> · </Text>
795
894
  <Text color={c.brand}>{item.exc.model}</Text>
796
895
  </Box>
@@ -1067,25 +1166,10 @@ function InputBox({ onSubmit, mode, onModeChange, visionSupported, placeholder,
1067
1166
  setTimeout(() => setPasteHint(null), 2500);
1068
1167
  return;
1069
1168
  }
1070
- if (key.escape) {
1071
- // ESC clears the input AND any attachments
1072
- if (value.length > 0 || attachments.length > 0) {
1073
- setValue('');
1074
- setAttachments([]);
1075
- return;
1076
- }
1077
- return;
1078
- }
1079
- // Tab: cycle modes UNLESS the slash popup is open (then it fills the highlighted command)
1080
- if (key.tab) {
1081
- if (slashOpen) {
1082
- const cmd = slashMatches[slashIdx];
1083
- if (cmd) setValue('/' + cmd.name + ' ');
1084
- return;
1085
- }
1086
- const i = MODES.findIndex(m => m.key === mode);
1087
- const next = MODES[(i + 1) % MODES.length]!;
1088
- onModeChange(next.key);
1169
+ // Tab: fill highlighted slash command if popup is open
1170
+ if (key.tab && slashOpen) {
1171
+ const cmd = slashMatches[slashIdx];
1172
+ if (cmd) setValue('/' + cmd.name + ' ');
1089
1173
  return;
1090
1174
  }
1091
1175
  if (!key.shift && (char === '1' || char === '2' || char === '3') && value.length === 0) {
@@ -1158,25 +1242,9 @@ function InputBox({ onSubmit, mode, onModeChange, visionSupported, placeholder,
1158
1242
 
1159
1243
  return (
1160
1244
  <Box flexDirection="column" borderStyle="round" borderColor={borderColor} marginTop={1}>
1161
- <Box paddingX={1} borderStyle="single" borderColor={c.borderDim} borderTop={false} borderLeft={false} borderRight={false} justifyContent="space-between">
1162
- <Box>
1163
- <Text color={modeInfo.color} bold>▶ </Text>
1164
- <Text color={c.text} bold>Input</Text>
1165
- <Text color={c.textDim}> · </Text>
1166
- <ModeSelector mode={mode} />
1167
- </Box>
1168
- <Box>
1169
- <Text color={c.textDim}>{visionSupported ? '⌘V ' : ''}</Text>
1170
- <Text color={c.textMuted}>{visionSupported ? 'paste image' : 'text only'}</Text>
1171
- <Text color={c.borderDim}> · </Text>
1172
- <Text color={c.textDim}>Tab </Text>
1173
- <Text color={c.textMuted}>switch</Text>
1174
- </Box>
1175
- </Box>
1176
-
1177
1245
  {/* Attachment chips */}
1178
1246
  {attachments.length > 0 && (
1179
- <Box paddingX={1} paddingTop={1} flexWrap="wrap">
1247
+ <Box paddingX={1} flexWrap="wrap">
1180
1248
  {attachments.map((a, i) => (
1181
1249
  <Box key={i} marginRight={1}>
1182
1250
  <Text color={c.accent}>📎 </Text>
@@ -1194,11 +1262,11 @@ function InputBox({ onSubmit, mode, onModeChange, visionSupported, placeholder,
1194
1262
  </Box>
1195
1263
  )}
1196
1264
 
1197
- <Box paddingX={1} paddingY={1}>
1265
+ <Box paddingX={1}>
1266
+ {visionSupported && <Text color={c.green}>🖼️ </Text>}
1198
1267
  <Text color={modeInfo.color}>❯ </Text>
1199
1268
  {isEmpty ? (
1200
1269
  <>
1201
- <Text color={c.borderDim}>{ph}</Text>
1202
1270
  {showCursor && <Text color={modeInfo.color}>{'▌'}</Text>}
1203
1271
  </>
1204
1272
  ) : (
@@ -1393,7 +1461,6 @@ function BottomBar({ phase, mode, isFirstStart }: { phase: Phase; mode: Mode; is
1393
1461
  const modeInfo = getMode(mode);
1394
1462
  return (
1395
1463
  <Box
1396
- marginTop={1}
1397
1464
  borderStyle="round"
1398
1465
  borderColor={c.borderDim}
1399
1466
  paddingX={1}
@@ -1403,12 +1470,6 @@ function BottomBar({ phase, mode, isFirstStart }: { phase: Phase; mode: Mode; is
1403
1470
  <Text color={c.green}>⏎ </Text>
1404
1471
  <Text color={c.textDim}>submit</Text>
1405
1472
  <Text color={c.borderDim}> · </Text>
1406
- <Text color={c.yellow}>Esc</Text>
1407
- <Text color={c.textDim}> clear</Text>
1408
- <Text color={c.borderDim}> · </Text>
1409
- <Text color={c.pink}>Tab</Text>
1410
- <Text color={c.textDim}> mode</Text>
1411
- <Text color={c.borderDim}> · </Text>
1412
1473
  <Text color={c.brand}>/models</Text>
1413
1474
  <Text color={c.textDim}> switch</Text>
1414
1475
  <Text color={c.borderDim}> · </Text>
@@ -1503,6 +1564,23 @@ function CodeEngine() {
1503
1564
  return () => clearInterval(t);
1504
1565
  }, []);
1505
1566
 
1567
+ // Load chat history on mount
1568
+ useEffect(() => {
1569
+ const { exchanges: savedExchanges, messages: savedMessages } = loadChatHistory();
1570
+ if (savedExchanges.length > 0) {
1571
+ setExchanges(savedExchanges);
1572
+ messagesRef.current = savedMessages;
1573
+ exchangesLenRef.current = savedExchanges.length;
1574
+ }
1575
+ }, []);
1576
+
1577
+ // Save chat history whenever exchanges change
1578
+ useEffect(() => {
1579
+ if (exchanges.length > 0) {
1580
+ saveChatHistory(exchanges, messagesRef.current);
1581
+ }
1582
+ }, [exchanges]);
1583
+
1506
1584
  // Auto-scroll to bottom on each new round
1507
1585
  useEffect(() => {
1508
1586
  if (exchanges.length !== exchangesLenRef.current) {
@@ -1519,9 +1597,9 @@ function CodeEngine() {
1519
1597
  ? now - startMs
1520
1598
  : (exchanges.length > 0 ? exchanges.reduce((s, e) => s + e.durationMs, 0) : 0);
1521
1599
 
1522
- // Reserve rows for: brand header (~10), input area (~3), bottom bar (~1),
1600
+ // Reserve rows for: brand header (~10), input area (~2), bottom bar (~1),
1523
1601
  // welcome footer when present (~5), and margins/padding (~3).
1524
- const RESERVED_ROWS = 22;
1602
+ const RESERVED_ROWS = 21;
1525
1603
  const convMaxHeight = Math.max(8, viewportRows - RESERVED_ROWS);
1526
1604
 
1527
1605
  // Dispatch a /slash command. Returns true if the input was a slash command
@@ -1553,6 +1631,7 @@ function CodeEngine() {
1553
1631
  messagesRef.current = [];
1554
1632
  exchangesLenRef.current = 0;
1555
1633
  setScrollOffset(0);
1634
+ clearChatHistory();
1556
1635
  showBanner('◆ session cleared');
1557
1636
  return true;
1558
1637
  case 'reset': {
@@ -1567,6 +1646,7 @@ function CodeEngine() {
1567
1646
  setExchanges([]);
1568
1647
  messagesRef.current = [];
1569
1648
  exchangesLenRef.current = 0;
1649
+ clearChatHistory();
1570
1650
  showBanner(`◆ reset → model ${newModel}`);
1571
1651
  return true;
1572
1652
  }
@@ -1751,6 +1831,10 @@ function CodeEngine() {
1751
1831
  )}
1752
1832
 
1753
1833
  <BottomBar phase={phase} mode={mode} isFirstStart={isFirstStart} />
1834
+
1835
+ <Box justifyContent="center">
1836
+ <Text color={c.textDim}>📁 {process.cwd()}</Text>
1837
+ </Box>
1754
1838
  </Box>
1755
1839
  );
1756
1840
  }
package/src/ui/colors.ts CHANGED
@@ -13,6 +13,7 @@ export const c = {
13
13
  accent: '#a78bfa', // purple
14
14
  green: '#34d399',
15
15
  yellow: '#fbbf24',
16
+ orange: '#fb923c',
16
17
  red: '#ef4444',
17
18
  pink: '#f472b6',
18
19
  blue: '#60a5fa',