kob-cli 1.0.20 → 1.0.22
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 +1 -1
- package/src/ui/code-tui.tsx +262 -382
package/package.json
CHANGED
package/src/ui/code-tui.tsx
CHANGED
|
@@ -592,285 +592,262 @@ interface Exchange {
|
|
|
592
592
|
mode: Mode;
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)
|
|
655
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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
|
|
844
|
+
<Text color={c.textDim}> ↑ {hiddenAbove} line{hiddenAbove === 1 ? '' : 's'} above (↑/PgUp scroll)</Text>
|
|
791
845
|
</Box>
|
|
792
846
|
)}
|
|
793
|
-
{
|
|
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
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
);
|
|
898
|
-
|
|
899
|
-
//
|
|
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 (
|
|
878
|
+
if (totalScrollable <= 0) return;
|
|
902
879
|
|
|
903
|
-
const
|
|
880
|
+
const PAGE_STEP = Math.max(2, maxHeight - 2);
|
|
904
881
|
if (key.pageUp) {
|
|
905
|
-
onScrollChange(Math.min(totalScrollable, scrollOffset +
|
|
882
|
+
onScrollChange(Math.min(totalScrollable, scrollOffset + PAGE_STEP));
|
|
906
883
|
} else if (key.pageDown) {
|
|
907
|
-
onScrollChange(Math.max(0, scrollOffset -
|
|
908
|
-
} else if (
|
|
909
|
-
onScrollChange(totalScrollable);
|
|
910
|
-
} else if (
|
|
911
|
-
onScrollChange(
|
|
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(
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
<
|
|
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),
|
|
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).
|
|
@@ -1511,9 +1390,11 @@ function CodeEngine() {
|
|
|
1511
1390
|
showBanner('◆ mode → Code');
|
|
1512
1391
|
return true;
|
|
1513
1392
|
case 'newchat':
|
|
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': {
|
|
@@ -1539,7 +1420,7 @@ function CodeEngine() {
|
|
|
1539
1420
|
return true;
|
|
1540
1421
|
case 'help':
|
|
1541
1422
|
case '?':
|
|
1542
|
-
showBanner('◆ /ask /plan /code /
|
|
1423
|
+
showBanner('◆ /ask /plan /code /clear /reset /models /config /help /exit');
|
|
1543
1424
|
return true;
|
|
1544
1425
|
case 'exit':
|
|
1545
1426
|
case 'quit':
|
|
@@ -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>
|