kob-cli 1.0.6 → 1.0.21

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.6",
3
+ "version": "1.0.21",
4
4
  "description": "KOB CLI — AI-powered code generation tool. Built by Kob AI, made in Thailand.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,12 +21,11 @@
21
21
  "scripts": {
22
22
  "dev": "bun run src/index.ts",
23
23
  "start": "bun run src/index.ts",
24
- "build": "bun build src/index.ts --compile --outfile kob-cli",
25
- "prepublishOnly": "bun run build",
26
- "publish": "bun run src/scripts/release.ts",
27
- "release:patch": "npm version patch --no-git-tag-version && npm publish",
28
- "release:minor": "npm version minor --no-git-tag-version && npm publish",
29
- "release:major": "npm version major --no-git-tag-version && npm publish"
24
+ "build": "bun run src/scripts/release.ts",
25
+ "build:only": "bun build src/index.ts --compile --outfile kob-cli",
26
+ "release:patch": "bun run src/scripts/release.ts patch",
27
+ "release:minor": "bun run src/scripts/release.ts minor",
28
+ "release:major": "bun run src/scripts/release.ts major"
30
29
  },
31
30
  "engines": {
32
31
  "node": ">=18.0.0"
@@ -2,15 +2,42 @@ import { readFileSync, writeFileSync } from 'fs';
2
2
  import { execSync } from 'child_process';
3
3
  import { resolve } from 'path';
4
4
 
5
- const pkgPath = resolve(import.meta.dir, '../../package.json');
5
+ const root = resolve(import.meta.dir, '../..');
6
+ const pkgPath = resolve(root, 'package.json');
6
7
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
7
8
 
9
+ const bumpType = (process.argv[2] as 'patch' | 'minor' | 'major') || 'patch';
10
+ const idx = { patch: 2, minor: 1, major: 0 }[bumpType];
8
11
  const parts = pkg.version.split('.').map(Number);
9
- parts[2] += 1;
12
+ parts[idx] += 1;
13
+ if (bumpType === 'major') parts[1] = 0;
14
+ if (bumpType !== 'patch') parts[2] = 0;
10
15
  pkg.version = parts.join('.');
11
16
 
12
17
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
13
- console.log(`\n 📦 ${pkg.name}@${pkg.version}\n`);
18
+ console.log(`\n 📦 ${pkg.name}@${pkg.version} (${bumpType})\n`);
14
19
 
15
- execSync('bun run build', { stdio: 'inherit', cwd: resolve(import.meta.dir, '../..') });
16
- execSync('npm publish --ignore-scripts', { stdio: 'inherit', cwd: resolve(import.meta.dir, '../..') });
20
+ try {
21
+ execSync('bun run build:only', { stdio: 'inherit', cwd: root });
22
+ } catch (e) {
23
+ console.error('❌ build failed — skipping publish & push');
24
+ process.exit(1);
25
+ }
26
+
27
+ try {
28
+ execSync('npm publish --ignore-scripts', { stdio: 'inherit', cwd: root });
29
+ } catch (e) {
30
+ console.error('❌ publish failed — version bumped, please check npm');
31
+ process.exit(1);
32
+ }
33
+
34
+ try {
35
+ execSync('git add package.json', { stdio: 'inherit', cwd: root });
36
+ execSync(`git commit -m "release: v${pkg.version}"`, { stdio: 'inherit', cwd: root });
37
+ execSync('git push', { stdio: 'inherit', cwd: root });
38
+ execSync('git tag v' + pkg.version, { stdio: 'inherit', cwd: root });
39
+ execSync('git push --tags', { stdio: 'inherit', cwd: root });
40
+ console.log(`\n ✅ released v${pkg.version}\n`);
41
+ } catch (e) {
42
+ console.error('⚠️ git push failed (published ok) — please push manually');
43
+ }
@@ -592,285 +592,262 @@ interface Exchange {
592
592
  mode: Mode;
593
593
  }
594
594
 
595
- function ResponseBox({ content, modeColor, maxLines = 14 }: { content: string; modeColor: string; maxLines?: number }) {
596
- const MAX_CHARS = 4000;
597
- const wasCharTruncated = content.length > MAX_CHARS;
598
- let text = wasCharTruncated ? content.slice(0, MAX_CHARS) : content;
599
- const allLines = text.split('\n');
600
- const wasLineTruncated = allLines.length > maxLines;
601
- if (wasLineTruncated) {
602
- text = allLines.slice(0, maxLines).join('\n');
603
- }
604
- const finalLines = text.split('\n');
595
+ // ============================================================================
596
+ // LINE BUFFER — flat list of renderable lines, built once per render.
597
+ // This is what makes scrolling smooth: the buffer is a stable array of
598
+ // lines, and the viewport is just a slice. No re-slicing of responses, no
599
+ // re-computation of heights, no layout shift while you scroll.
600
+ // ============================================================================
601
+ const MAX_RESP_LINES = 14;
602
+ const MAX_CMD_CHARS = 1200;
603
+ const MAX_CMD_LINES = 8;
604
+
605
+ type LineItem =
606
+ | { kind: 'header'; roundIdx: number; durationMs: number; modeIcon: string; modeLabel: string; modeColor: string }
607
+ | { kind: 'input'; text: string }
608
+ | { kind: 'response-line'; text: string; modeColor: string; isFirst: boolean }
609
+ | { kind: 'response-trunc'; totalChars: number; totalLines: number }
610
+ | { kind: 'no-response' }
611
+ | { kind: 'file-line'; filename: string; add: number; del: number; created: boolean }
612
+ | { kind: 'cmd-header'; cmd: string; ok: boolean; statusColor: string; exitCode: number; durationMs: number }
613
+ | { kind: 'cmd-output'; text: string; statusColor: string }
614
+ | { kind: 'cmd-trunc'; hidden: number; statusColor: string }
615
+ | { kind: 'meta'; exc: Exchange }
616
+ | { kind: 'separator' }
617
+ | { kind: 'blank' };
618
+
619
+ function buildLineBuffer(exchanges: Exchange[]): LineItem[] {
620
+ const buf: LineItem[] = [];
621
+ exchanges.forEach((exc, i) => {
622
+ const m = getMode(exc.mode);
623
+ const isLast = i === exchanges.length - 1;
624
+
625
+ // Round header
626
+ buf.push({
627
+ kind: 'header',
628
+ roundIdx: i,
629
+ durationMs: exc.durationMs,
630
+ modeIcon: m.icon,
631
+ modeLabel: m.label,
632
+ modeColor: m.color,
633
+ });
605
634
 
606
- return (
607
- <Box flexDirection="column" marginTop={1} marginLeft={2}>
608
- {finalLines.map((line, i) => (
609
- <Box key={i}>
610
- {i === 0 ? (
611
- <Text color={modeColor} bold>◀ </Text>
612
- ) : (
613
- <Text>{' '}</Text>
614
- )}
615
- <Text color={c.text}>{line.length === 0 ? ' ' : line}</Text>
616
- </Box>
617
- ))}
618
- {(wasCharTruncated || wasLineTruncated) && (
619
- <Text color={c.textDim}> … (truncated, full response: {content.length} chars / {allLines.length} lines)</Text>
620
- )}
621
- </Box>
622
- );
623
- }
635
+ // Input
636
+ buf.push({ kind: 'input', text: exc.input });
637
+
638
+ // Response (truncated ONCE here, at build time, so it never re-slices)
639
+ if (exc.output.trim().length > 0) {
640
+ const respLines = exc.output.split('\n');
641
+ const total = respLines.length;
642
+ const trunc = total > MAX_RESP_LINES;
643
+ const shown = trunc ? respLines.slice(0, MAX_RESP_LINES) : respLines;
644
+ shown.forEach((line, j) => {
645
+ buf.push({
646
+ kind: 'response-line',
647
+ text: line,
648
+ modeColor: m.color,
649
+ isFirst: j === 0,
650
+ });
651
+ });
652
+ if (trunc) {
653
+ buf.push({ kind: 'response-trunc', totalChars: exc.output.length, totalLines: total });
654
+ }
655
+ } else {
656
+ buf.push({ kind: 'no-response' });
657
+ }
624
658
 
625
- function CommandResultBox({ result }: { result: CommandResult }) {
626
- const MAX_OUT_CHARS = 1200;
627
- const MAX_OUT_LINES = 8;
628
- const out = (result.stdout || '') + (result.stderr ? '\n' + result.stderr : '');
629
- const trimmed = out.length > MAX_OUT_CHARS ? out.slice(0, MAX_OUT_CHARS) : out;
630
- const lines = trimmed.split('\n');
631
- const truncated = lines.length > MAX_OUT_LINES ? lines.slice(0, MAX_OUT_LINES) : lines;
632
- const statusColor = result.ok ? c.green : c.red;
633
- const statusIcon = result.ok ? '✓' : '✗';
659
+ // Files
660
+ exc.files.forEach((f) => {
661
+ buf.push({
662
+ kind: 'file-line',
663
+ filename: f.filename,
664
+ add: f.addLines,
665
+ del: f.delLines,
666
+ created: exc.created.includes(f.filename),
667
+ });
668
+ });
634
669
 
635
- return (
636
- <Box flexDirection="column" borderStyle="single" borderColor={statusColor} paddingX={1} marginTop={1} marginLeft={2}>
637
- <Box>
638
- <Text color={statusColor} bold>{statusIcon} </Text>
639
- <Text color={c.textDim}>$ </Text>
640
- <Text color={c.text} bold>{result.cmd.length > 80 ? result.cmd.slice(0, 77) + '...' : result.cmd}</Text>
641
- <Text color={c.textDim}> · </Text>
642
- <Text color={statusColor}>exit {result.exitCode}</Text>
643
- <Text color={c.textDim}> · {formatDuration(result.durationMs)}</Text>
644
- </Box>
645
- {truncated.length > 0 && (
646
- <Box flexDirection="column" marginTop={1}>
647
- {truncated.map((line, i) => (
648
- <Text key={i} color={c.textMuted}>{line.length === 0 ? ' ' : line}</Text>
649
- ))}
650
- {lines.length > MAX_OUT_LINES && (
651
- <Text color={c.textDim}> … ({lines.length - MAX_OUT_LINES} more lines)</Text>
652
- )}
653
- </Box>
654
- )}
655
- </Box>
656
- );
657
- }
670
+ // Commands
671
+ exc.commandResults.forEach((r) => {
672
+ const statusColor = r.ok ? c.green : c.red;
673
+ buf.push({
674
+ kind: 'cmd-header',
675
+ cmd: r.cmd,
676
+ ok: r.ok,
677
+ statusColor,
678
+ exitCode: r.exitCode,
679
+ durationMs: r.durationMs,
680
+ });
681
+ const out = (r.stdout || '') + (r.stderr ? '\n' + r.stderr : '');
682
+ const trimmed = out.length > MAX_CMD_CHARS ? out.slice(0, MAX_CMD_CHARS) : out;
683
+ const lines = trimmed.split('\n');
684
+ const outTrunc = lines.length > MAX_CMD_LINES;
685
+ const shownLines = outTrunc ? lines.slice(0, MAX_CMD_LINES) : lines;
686
+ shownLines.forEach((line) => {
687
+ buf.push({ kind: 'cmd-output', text: line, statusColor });
688
+ });
689
+ if (outTrunc) {
690
+ buf.push({ kind: 'cmd-trunc', hidden: lines.length - MAX_CMD_LINES, statusColor });
691
+ }
692
+ });
658
693
 
659
- function estimateRoundHeight(exc: Exchange, respMax: number): number {
660
- const totalResp = exc.output === '' ? 0 : exc.output.split('\n').length;
661
- const shownResp = Math.min(respMax, totalResp);
662
- const truncMarker = exc.output.trim().length > 0 && totalResp > respMax ? 1 : 0;
663
- const noRespLine = exc.output.trim().length === 0 ? 1 : 0;
664
-
665
- let h = 0;
666
- h += 1; // round header
667
- h += 1; // input line
668
- h += 1; // response top margin
669
- h += shownResp + truncMarker + noRespLine;
670
-
671
- if (exc.files.length > 0) h += 1 + exc.files.length;
672
-
673
- for (const r of exc.commandResults) {
674
- h += 1; // top margin
675
- h += 2; // top + bottom border
676
- h += 1; // header
677
- const out = (r.stdout || '') + (r.stderr ? '\n' + r.stderr : '');
678
- const outLines = Math.min(8, out.split('\n').length);
679
- h += outLines;
680
- }
694
+ // Meta
695
+ buf.push({ kind: 'meta', exc });
681
696
 
682
- h += 1; // meta margin
683
- h += 1; // meta line
684
- h += 2; // blank + separator
685
- return h;
697
+ if (!isLast) {
698
+ buf.push({ kind: 'blank' });
699
+ buf.push({ kind: 'separator' });
700
+ buf.push({ kind: 'blank' });
701
+ }
702
+ });
703
+ return buf;
686
704
  }
687
705
 
688
- function getVisibleWindow(
689
- exchanges: Exchange[],
690
- maxHeight: number,
691
- scrollOffset: number,
692
- defaultRespMax: number
693
- ): { visible: Array<{ exc: Exchange; respMax: number; isFirst: boolean; isLast: boolean }>; hiddenAbove: number; hiddenBelow: number } {
694
- if (exchanges.length === 0 || maxHeight <= 0) {
695
- return { visible: [], hiddenAbove: 0, hiddenBelow: 0 };
696
- }
697
-
698
- const items = exchanges.map((exc) => ({
699
- exc,
700
- respMax: defaultRespMax,
701
- height: estimateRoundHeight(exc, defaultRespMax),
702
- }));
703
- const totalH = items.reduce((s, it) => s + it.height, 0);
704
-
705
- if (totalH <= maxHeight) {
706
- return {
707
- visible: items.map((it) => ({ exc: it.exc, respMax: it.respMax, isFirst: false, isLast: false })),
708
- hiddenAbove: 0,
709
- hiddenBelow: 0,
710
- };
711
- }
712
-
713
- // scrollOffset = lines hidden from top (0 = bottom-aligned)
714
- const maxScroll = totalH - maxHeight;
715
- const effOffset = Math.max(0, Math.min(scrollOffset, maxScroll));
716
- const startLine = totalH - maxHeight - effOffset;
717
- const endLine = startLine + maxHeight;
718
-
719
- const visible: Array<{ exc: Exchange; respMax: number; isFirst: boolean; isLast: boolean }> = [];
720
- let accum = 0;
721
- let isFirst = true;
722
- for (const it of items) {
723
- const itemStart = accum;
724
- const itemEnd = accum + it.height;
725
- accum = itemEnd;
726
-
727
- if (itemEnd <= startLine) continue; // fully above
728
- if (itemStart >= endLine) break; // fully below
729
-
730
- const visTop = Math.max(0, startLine - itemStart);
731
- const visBot = Math.min(it.height, endLine - itemStart);
732
-
733
- if (visTop === 0 && visBot === it.height) {
734
- // fully visible
735
- visible.push({ exc: it.exc, respMax: it.respMax, isFirst, isLast: false });
736
- } else {
737
- // partially visible — slice response to fit
738
- const linesBefore = 3; // round header + input + response top margin
739
- const respTotal = it.exc.output.split('\n').length;
740
- const respTrunc = it.exc.output.trim().length > 0 && respTotal > it.respMax ? 1 : 0;
741
- const noResp = it.exc.output.trim().length === 0 ? 1 : 0;
742
- const respBlock = Math.min(it.respMax, respTotal) + respTrunc + noResp;
743
- const linesAfter = Math.max(0, it.height - linesBefore - respBlock);
744
-
745
- const available = visBot - visTop;
746
- const slicedRespMax = Math.max(0, available - linesBefore - linesAfter);
747
- visible.push({ exc: it.exc, respMax: slicedRespMax, isFirst, isLast: false });
748
- }
749
- isFirst = false;
706
+ function renderLine(item: LineItem, idx: number): React.ReactNode {
707
+ const key = `L${idx}-${item.kind}`;
708
+ switch (item.kind) {
709
+ case 'header':
710
+ return (
711
+ <Box key={key}>
712
+ <Text color={c.accent} bold>✦ Round {item.roundIdx + 1}</Text>
713
+ <Text color={c.textDim}> · {formatDuration(item.durationMs)}</Text>
714
+ <Text color={c.textDim}> · </Text>
715
+ <Text color={item.modeColor}>{item.modeIcon} {item.modeLabel}</Text>
716
+ </Box>
717
+ );
718
+ case 'input':
719
+ return (
720
+ <Box key={key} marginLeft={2}>
721
+ <Text color={c.textDim}>❯ </Text>
722
+ <Text color={c.text}>{item.text.length > 70 ? item.text.slice(0, 67) + '...' : item.text}</Text>
723
+ </Box>
724
+ );
725
+ case 'response-line':
726
+ return (
727
+ <Box key={key} marginLeft={2}>
728
+ {item.isFirst ? (
729
+ <Text color={item.modeColor} bold>◀ </Text>
730
+ ) : (
731
+ <Text>{' '}</Text>
732
+ )}
733
+ <Text color={c.text}>{item.text.length === 0 ? ' ' : item.text}</Text>
734
+ </Box>
735
+ );
736
+ case 'response-trunc':
737
+ return (
738
+ <Box key={key} marginLeft={2}>
739
+ <Text color={c.textDim}> … (truncated, full response: {item.totalChars} chars / {item.totalLines} lines)</Text>
740
+ </Box>
741
+ );
742
+ case 'no-response':
743
+ return (
744
+ <Box key={key} marginLeft={2}>
745
+ <Text color={c.red}>✗ (no response received)</Text>
746
+ </Box>
747
+ );
748
+ case 'file-line':
749
+ return (
750
+ <Box key={key} marginLeft={2}>
751
+ <Text color={item.created ? c.green : c.yellow}>{item.created ? '✓' : '●'}</Text>
752
+ <Text> </Text>
753
+ <Text color={c.text} bold>{item.filename}</Text>
754
+ <Text color={c.textDim}> </Text>
755
+ <Text color={c.green}>+{item.add}</Text>
756
+ {item.del > 0 && (
757
+ <>
758
+ <Text color={c.textDim}> </Text>
759
+ <Text color={c.red}>-{item.del}</Text>
760
+ </>
761
+ )}
762
+ </Box>
763
+ );
764
+ case 'cmd-header':
765
+ return (
766
+ <Box key={key} marginLeft={2} marginTop={1}>
767
+ <Text color={item.statusColor} bold>{item.ok ? '✓ ' : '✗ '}</Text>
768
+ <Text color={c.textDim}>$ </Text>
769
+ <Text color={c.text} bold>{item.cmd.length > 80 ? item.cmd.slice(0, 77) + '...' : item.cmd}</Text>
770
+ <Text color={c.textDim}> · </Text>
771
+ <Text color={item.statusColor}>exit {item.exitCode}</Text>
772
+ <Text color={c.textDim}> · {formatDuration(item.durationMs)}</Text>
773
+ </Box>
774
+ );
775
+ case 'cmd-output':
776
+ return (
777
+ <Box key={key} marginLeft={4}>
778
+ <Text color={c.textMuted}>{item.text.length === 0 ? ' ' : item.text}</Text>
779
+ </Box>
780
+ );
781
+ case 'cmd-trunc':
782
+ return (
783
+ <Box key={key} marginLeft={4}>
784
+ <Text color={c.textDim}> … ({item.hidden} more lines)</Text>
785
+ </Box>
786
+ );
787
+ case 'meta':
788
+ return (
789
+ <Box key={key} marginLeft={2} marginTop={1}>
790
+ <Text color={c.textDim}>↳ </Text>
791
+ <Text color={c.textMuted}>{item.exc.output.length} chars</Text>
792
+ <Text color={c.textDim}> · </Text>
793
+ <Text color={c.textMuted}>↓ {formatNum(item.exc.inTokens)} ↑ {formatNum(item.exc.outTokens)} tok</Text>
794
+ <Text color={c.textDim}> · </Text>
795
+ <Text color={c.brand}>{item.exc.model}</Text>
796
+ </Box>
797
+ );
798
+ case 'separator':
799
+ return (
800
+ <Box key={key}>
801
+ <Text color={c.borderDim}>{'─'.repeat(60)}</Text>
802
+ </Box>
803
+ );
804
+ case 'blank':
805
+ return <Box key={key}><Text> </Text></Box>;
750
806
  }
751
-
752
- return {
753
- visible,
754
- hiddenAbove: startLine,
755
- hiddenBelow: Math.max(0, totalH - endLine),
756
- };
757
807
  }
758
808
 
759
809
  function ConversationView({
760
810
  exchanges,
761
811
  maxHeight,
762
812
  scrollOffset,
763
- defaultRespMax,
764
813
  }: {
765
814
  exchanges: Exchange[];
766
815
  maxHeight: number;
767
816
  scrollOffset: number;
768
- defaultRespMax: number;
769
817
  }) {
770
- if (exchanges.length === 0) {
771
- return (
772
- <Box flexDirection="column" paddingX={1} paddingY={1}>
773
- <Text color={c.textDim}>No rounds yet.</Text>
774
- <Text color={c.textMuted}> Ask the model to start generating code.</Text>
775
- </Box>
776
- );
777
- }
778
-
779
- const { visible, hiddenAbove, hiddenBelow } = getVisibleWindow(
780
- exchanges,
781
- maxHeight,
782
- scrollOffset,
783
- defaultRespMax
784
- );
818
+ if (exchanges.length === 0) return null;
819
+
820
+ // Build the flat line buffer once per render. The buffer is the
821
+ // single source of truth for layout heights — scroll position is
822
+ // just an index into it, so the view never re-slices responses.
823
+ const buffer = buildLineBuffer(exchanges);
824
+ const totalH = buffer.length;
825
+
826
+ // scrollOffset = lines hidden from the top of the buffer.
827
+ // 0 → bottom of conversation (latest content)
828
+ // max → top of conversation (oldest content)
829
+ const totalScrollable = Math.max(0, totalH - maxHeight);
830
+ const effOffset = Math.max(0, Math.min(scrollOffset, totalScrollable));
831
+ const startIdx = totalH - maxHeight - effOffset;
832
+ const endIdx = Math.min(totalH, startIdx + maxHeight);
833
+ const hiddenAbove = startIdx;
834
+ const hiddenBelow = Math.max(0, totalH - endIdx);
835
+
836
+ const window = buffer.slice(Math.max(0, startIdx), endIdx);
785
837
 
786
838
  return (
787
- <Box flexDirection="column" paddingX={1} paddingY={1}>
839
+ // No border, no header — keep this area open and spacious.
840
+ // The welcome / tips now live at the bottom of the screen.
841
+ <Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
788
842
  {hiddenAbove > 0 && (
789
843
  <Box>
790
- <Text color={c.textDim}> ↑ {hiddenAbove} line{hiddenAbove === 1 ? '' : 's'} above (PgUp to scroll)</Text>
844
+ <Text color={c.textDim}> ↑ {hiddenAbove} line{hiddenAbove === 1 ? '' : 's'} above (↑/PgUp scroll)</Text>
791
845
  </Box>
792
846
  )}
793
- {visible.map((v, i) => {
794
- const m = getMode(v.exc.mode);
795
- const isLast = i === visible.length - 1;
796
- return (
797
- <Box key={i} flexDirection="column" marginBottom={isLast ? 0 : 1}>
798
- <Box>
799
- <Text color={c.accent} bold>✦ Round {i + 1}</Text>
800
- <Text color={c.textDim}> · {formatDuration(v.exc.durationMs)}</Text>
801
- <Text color={c.textDim}> · </Text>
802
- <Text color={m.color}>{m.icon} {m.label}</Text>
803
- </Box>
804
-
805
- {/* Input line */}
806
- <Box marginTop={1} marginLeft={2}>
807
- <Text color={c.textDim}>❯ </Text>
808
- <Text color={c.text}>{v.exc.input.length > 70 ? v.exc.input.slice(0, 67) + '...' : v.exc.input}</Text>
809
- </Box>
810
-
811
- {/* Response content — the actual AI output */}
812
- {v.exc.output.trim().length > 0 ? (
813
- <ResponseBox content={v.exc.output} modeColor={m.color} maxLines={v.respMax} />
814
- ) : (
815
- <Box marginTop={1} marginLeft={2}>
816
- <Text color={c.red}>✗ (no response received)</Text>
817
- </Box>
818
- )}
819
-
820
- {/* Files written (code mode only) */}
821
- {v.exc.files.length > 0 && (
822
- <Box flexDirection="column" marginTop={1} marginLeft={2}>
823
- {v.exc.files.map((f, j) => {
824
- const isCreated = v.exc.created.includes(f.filename);
825
- return (
826
- <Box key={j}>
827
- <Text color={isCreated ? c.green : c.yellow}>{isCreated ? '✓' : '●'}</Text>
828
- <Text> </Text>
829
- <Text color={c.text} bold>{f.filename}</Text>
830
- <Text color={c.textDim}> </Text>
831
- <Text color={c.green}>+{f.addLines}</Text>
832
- {f.delLines > 0 && (
833
- <>
834
- <Text color={c.textDim}> </Text>
835
- <Text color={c.red}>-{f.delLines}</Text>
836
- </>
837
- )}
838
- </Box>
839
- );
840
- })}
841
- </Box>
842
- )}
843
-
844
- {/* Commands run (auto-executed bash blocks) */}
845
- {v.exc.commandResults.length > 0 && (
846
- <Box flexDirection="column">
847
- {v.exc.commandResults.map((r, j) => (
848
- <CommandResultBox key={j} result={r} />
849
- ))}
850
- </Box>
851
- )}
852
-
853
- {/* Meta line */}
854
- <Box marginTop={1} marginLeft={2}>
855
- <Text color={c.textDim}>↳ </Text>
856
- <Text color={c.textMuted}>{v.exc.output.length} chars</Text>
857
- <Text color={c.textDim}> · </Text>
858
- <Text color={c.textMuted}>↓ {formatNum(v.exc.inTokens)} ↑ {formatNum(v.exc.outTokens)} tok</Text>
859
- <Text color={c.textDim}> · </Text>
860
- <Text color={c.brand}>{v.exc.model}</Text>
861
- </Box>
862
-
863
- {!isLast && (
864
- <Box marginTop={1}>
865
- <Text color={c.borderDim}>{'─'.repeat(60)}</Text>
866
- </Box>
867
- )}
868
- </Box>
869
- );
870
- })}
847
+ {window.map((item, i) => renderLine(item, startIdx + i))}
871
848
  {hiddenBelow > 0 && (
872
849
  <Box>
873
- <Text color={c.textDim}> ↓ {hiddenBelow} line{hiddenBelow === 1 ? '' : 's'} below (PgDn to scroll)</Text>
850
+ <Text color={c.textDim}> ↓ {hiddenBelow} line{hiddenBelow === 1 ? '' : 's'} below (↓/PgDn scroll)</Text>
874
851
  </Box>
875
852
  )}
876
853
  </Box>
@@ -881,160 +858,62 @@ function ConversationPanel({
881
858
  exchanges,
882
859
  maxHeight,
883
860
  scrollOffset,
884
- defaultRespMax,
885
861
  onScrollChange,
886
862
  }: {
887
863
  exchanges: Exchange[];
888
864
  maxHeight: number;
889
865
  scrollOffset: number;
890
- defaultRespMax: number;
891
866
  onScrollChange: (newOffset: number) => void;
892
867
  }) {
893
- const { hiddenAbove, hiddenBelow, totalScrollable } = getScrollStats(
894
- exchanges,
895
- maxHeight,
896
- defaultRespMax
897
- );
898
-
899
- // Detect mouse wheel on the panel (only when exchanges overflow)
868
+ // Build the same buffer to compute scroll bounds. We could lift the
869
+ // buffer into CodeEngine and pass it down, but rebuilding it is cheap
870
+ // (O(n) over the exchanges, which is small).
871
+ const buffer = exchanges.length === 0 ? [] : buildLineBuffer(exchanges);
872
+ const totalScrollable = Math.max(0, buffer.length - maxHeight);
873
+
874
+ // Scroll input handles both line-by-line (↑/↓) and page (PgUp/PgDn)
875
+ // so users get smooth single-line motion AND big jumps when they want
876
+ // to fly through long histories.
900
877
  useInput((input, key) => {
901
- if (exchanges.length === 0 || totalScrollable <= 0) return;
878
+ if (totalScrollable <= 0) return;
902
879
 
903
- const STEP = 3;
880
+ const PAGE_STEP = Math.max(2, maxHeight - 2);
904
881
  if (key.pageUp) {
905
- onScrollChange(Math.min(totalScrollable, scrollOffset + STEP));
882
+ onScrollChange(Math.min(totalScrollable, scrollOffset + PAGE_STEP));
906
883
  } else if (key.pageDown) {
907
- onScrollChange(Math.max(0, scrollOffset - STEP));
908
- } else if (input === 'g' && key.shift) {
909
- onScrollChange(totalScrollable);
910
- } else if (input === 'G') {
911
- onScrollChange(totalScrollable);
884
+ onScrollChange(Math.max(0, scrollOffset - PAGE_STEP));
885
+ } else if (key.upArrow) {
886
+ onScrollChange(Math.min(totalScrollable, scrollOffset + 1));
887
+ } else if (key.downArrow) {
888
+ onScrollChange(Math.max(0, scrollOffset - 1));
912
889
  } else if (input === 'g' && !key.shift) {
913
- onScrollChange(0);
890
+ onScrollChange(totalScrollable); // top of buffer
891
+ } else if (input === 'G' || (input === 'g' && key.shift)) {
892
+ onScrollChange(0); // bottom of buffer (latest)
914
893
  }
915
894
  }, { isActive: true });
916
895
 
917
896
  return (
918
- <Box
919
- flexDirection="column"
920
- flexGrow={1}
921
- borderStyle="round"
922
- borderColor={c.border}
923
- >
924
- <Box paddingX={1} borderStyle="single" borderColor={c.borderDim} borderTop={false} borderLeft={false} borderRight={false}>
925
- <Text color={c.brand} bold>◇ </Text>
926
- <Text color={c.text} bold>Conversation</Text>
927
- <Box flexGrow={1} />
928
- <Text color={c.textDim}>{exchanges.length} round{exchanges.length === 1 ? '' : 's'}</Text>
929
- {totalScrollable > 0 && (
930
- <Text color={c.textDim}>
931
- {' '}↑ {hiddenAbove}/{hiddenAbove + hiddenBelow + maxHeight}
932
- </Text>
933
- )}
934
- </Box>
897
+ // Borderless, headerless — the conversation flows freely so the
898
+ // middle of the screen stays open and spacious. When there are
899
+ // no rounds yet, we render nothing here; the welcome content
900
+ // (modes + tips) lives at the bottom of the screen.
901
+ <Box flexDirection="column" flexGrow={1}>
935
902
  {exchanges.length === 0 ? (
936
- <WelcomeHero />
903
+ <Box paddingX={1} paddingY={2}>
904
+ <Text color={c.textMuted}> </Text>
905
+ </Box>
937
906
  ) : (
938
907
  <ConversationView
939
908
  exchanges={exchanges}
940
909
  maxHeight={maxHeight}
941
910
  scrollOffset={scrollOffset}
942
- defaultRespMax={defaultRespMax}
943
911
  />
944
912
  )}
945
913
  </Box>
946
914
  );
947
915
  }
948
916
 
949
- function getScrollStats(
950
- exchanges: Exchange[],
951
- maxHeight: number,
952
- defaultRespMax: number
953
- ): { hiddenAbove: number; hiddenBelow: number; totalScrollable: number } {
954
- if (exchanges.length === 0 || maxHeight <= 0) {
955
- return { hiddenAbove: 0, hiddenBelow: 0, totalScrollable: 0 };
956
- }
957
- const totalH = exchanges.reduce((s, e) => s + estimateRoundHeight(e, defaultRespMax), 0);
958
- if (totalH <= maxHeight) {
959
- return { hiddenAbove: 0, hiddenBelow: 0, totalScrollable: 0 };
960
- }
961
- return { hiddenAbove: 0, hiddenBelow: totalH - maxHeight, totalScrollable: totalH - maxHeight };
962
- }
963
-
964
- // ============================================================================
965
- // WELCOME HERO — shown when no rounds yet
966
- // ============================================================================
967
- function WelcomeHero() {
968
- const { frame } = useAnimation({ interval: 1000 });
969
- const pulse = ['█', '▓', '▒', '░', '▒', '▓'];
970
- const wave = pulse[frame % pulse.length]!;
971
-
972
- return (
973
- <Box flexDirection="column" paddingX={1} paddingY={1}>
974
- {/* Big greeting line */}
975
- <Box>
976
- <Text color={c.brand} bold>{wave} </Text>
977
- <Text color={c.text} bold>Welcome to KOB Code Engine</Text>
978
- </Box>
979
- <Box marginTop={1}>
980
- <Text color={c.textMuted}> Multi-turn code generation powered by AI.</Text>
981
- </Box>
982
- <Box>
983
- <Text color={c.textMuted}> Choose a mode and start chatting. Switch any time with </Text>
984
- <Text color={c.pink}>Tab</Text>
985
- <Text color={c.textMuted}>.</Text>
986
- </Box>
987
-
988
- {/* Modes */}
989
- <Box marginTop={2}>
990
- <Text color={c.accent} bold>✦ Three modes</Text>
991
- </Box>
992
- <Box flexDirection="column" marginTop={1} marginLeft={2}>
993
- {MODES.map(m => (
994
- <Box key={m.key}>
995
- <Text color={m.color} bold>{m.icon} {m.label}</Text>
996
- <Text color={c.textDim}> [{m.shortcut}] </Text>
997
- <Text color={c.textMuted}>{m.description}</Text>
998
- </Box>
999
- ))}
1000
- </Box>
1001
-
1002
- {/* Try saying REMOVED */}
1003
-
1004
- {/* Tips */}
1005
- <Box marginTop={2}>
1006
- <Text color={c.yellow} bold>✦ Tips</Text>
1007
- </Box>
1008
- <Box flexDirection="column" marginTop={1} marginLeft={2}>
1009
- <Box>
1010
- <Text color={c.borderAccent}>• </Text>
1011
- <Text color={c.textMuted}>Press </Text>
1012
- <Text color={c.pink}>Tab</Text>
1013
- <Text color={c.textMuted}> to cycle modes, or </Text>
1014
- <Text color={c.pink}>1</Text>
1015
- <Text color={c.textDim}>/</Text>
1016
- <Text color={c.pink}>2</Text>
1017
- <Text color={c.textDim}>/</Text>
1018
- <Text color={c.pink}>3</Text>
1019
- <Text color={c.textMuted}> to jump</Text>
1020
- </Box>
1021
- <Box>
1022
- <Text color={c.borderAccent}>• </Text>
1023
- <Text color={c.textMuted}>Mode can be changed any time between rounds</Text>
1024
- </Box>
1025
- <Box>
1026
- <Text color={c.borderAccent}>• </Text>
1027
- <Text color={c.textMuted}>Type </Text>
1028
- <Text color={c.pink}>/exit</Text>
1029
- <Text color={c.textMuted}> to quit, </Text>
1030
- <Text color={c.pink}>Esc</Text>
1031
- <Text color={c.textMuted}> to clear the input</Text>
1032
- </Box>
1033
- </Box>
1034
- </Box>
1035
- );
1036
- }
1037
-
1038
917
  // ============================================================================
1039
918
  // GENERATING ANIMATION
1040
919
  // ============================================================================
@@ -1482,10 +1361,10 @@ function CodeEngine() {
1482
1361
  ? now - startMs
1483
1362
  : (exchanges.length > 0 ? exchanges.reduce((s, e) => s + e.durationMs, 0) : 0);
1484
1363
 
1485
- // Reserve rows for: brand header (~10), input area (~3), bottom bar (~1), margins/padding (~6)
1364
+ // Reserve rows for: brand header (~10), input area (~3), bottom bar (~1),
1365
+ // welcome footer when present (~5), and margins/padding (~3).
1486
1366
  const RESERVED_ROWS = 22;
1487
1367
  const convMaxHeight = Math.max(8, viewportRows - RESERVED_ROWS);
1488
- const DEFAULT_RESP_MAX = 14;
1489
1368
 
1490
1369
  // Dispatch a /slash command. Returns true if the input was a slash command
1491
1370
  // (and therefore should NOT be sent to the model).
@@ -1510,10 +1389,12 @@ function CodeEngine() {
1510
1389
  setMode('code');
1511
1390
  showBanner('◆ mode → Code');
1512
1391
  return true;
1392
+ case 'newchat':
1513
1393
  case 'clear':
1514
1394
  setExchanges([]);
1515
1395
  messagesRef.current = [];
1516
1396
  exchangesLenRef.current = 0;
1397
+ setScrollOffset(0);
1517
1398
  showBanner('◆ session cleared');
1518
1399
  return true;
1519
1400
  case 'reset': {
@@ -1666,7 +1547,6 @@ function CodeEngine() {
1666
1547
  exchanges={exchanges}
1667
1548
  maxHeight={convMaxHeight}
1668
1549
  scrollOffset={scrollOffset}
1669
- defaultRespMax={DEFAULT_RESP_MAX}
1670
1550
  onScrollChange={setScrollOffset}
1671
1551
  />
1672
1552
  </Box>
@@ -1677,9 +1557,6 @@ function CodeEngine() {
1677
1557
  setModel(modelId);
1678
1558
  configRef.current = { ...configRef.current, modelId };
1679
1559
  setPalette(null);
1680
- setExchanges([]);
1681
- messagesRef.current = [];
1682
- exchangesLenRef.current = 0;
1683
1560
  showBanner(`◆ model → ${displayName} (${modelId})`);
1684
1561
  }}
1685
1562
  onClose={() => setPalette(null)}
@@ -52,6 +52,16 @@ export function ConfigForm({ onDone }: Props) {
52
52
  }, [idx]);
53
53
 
54
54
  useInput((input, key) => {
55
+ // After saving we're just waiting for the user to acknowledge —
56
+ // any key (Enter / Esc / printable) closes the overlay so focus
57
+ // returns to the chat. Without this, the form would lock the
58
+ // keyboard: InputBox is also disabled while configOpen is true.
59
+ if (saved) {
60
+ if (input || key.return || key.escape || key.backspace || key.tab) {
61
+ onDone(true);
62
+ }
63
+ return;
64
+ }
55
65
  if (!editing) return;
56
66
  if (key.escape) {
57
67
  onDone(false);