anvilsign in

collin/strudel-claude

pre-demo / node-tui / ui / Chat.ts
1import { Box, Text } from 'ink';
2import { memo } from 'react';
3import type { ReactElement } from 'react';
4import { html } from './html.ts';
5import type { Turn } from '../claude.ts';
6
7/**
8 * Wrap text to a width by words, so the transcript reads as prose rather than
9 * being chopped mid-word.
10 *
11 * Ink can wrap a <Text> itself, but doing it here means the caller knows how
12 * many rows a turn will occupy — which is what lets the pane show the *end* of
13 * a long conversation instead of the start.
14 */
15function wrap(text: string, width: number): string[] {
16 const out: string[] = [];
17 for (const paragraph of text.split('\n')) {
18 let line = '';
19 for (const word of paragraph.split(/\s+/).filter(Boolean)) {
20 if (line === '') line = word;
21 else if (line.length + 1 + word.length <= width) line += ` ${word}`;
22 else { out.push(line); line = word; }
23 }
24 out.push(line);
25 }
26 return out;
27}
28
29export interface ChatProps {
30 turns: readonly Turn[];
31 /** Text the user is composing, or null when the composer isn't focused. */
32 draft: string | null;
33 pending: boolean;
34 error: string | null;
35 /** Whether ANTHROPIC_API_KEY is set — shown before the first turn. */
36 hasKey: boolean;
37 /** Whether the pane holds focus, which the prompt shows. */
38 focused: boolean;
39 width: number;
40 height: number;
41}
42
43export const Chat = memo(function Chat({
44 turns, draft, pending, error, hasKey, focused, width, height,
45}: ChatProps): ReactElement {
46 // Rows available for the transcript, once the composer and any error line
47 // have taken theirs.
48 const body = Math.max(1, height - 1 - (error ? 1 : 0));
49
50 // Build the transcript bottom-up and stop once the pane is full: the newest
51 // exchange is the one you need to see, and rendering from the top would push
52 // it off the bottom in any conversation longer than the pane.
53 //
54 // The budget is a hard limit, not a stopping condition. Emitting a whole turn
55 // because *some* of it fit overflows the box, and Ink's response to overflow
56 // is to draw rows on top of each other — which looks like corrupted text, not
57 // like a layout bug, and is very hard to read backwards. A turn that only
58 // partly fits is therefore truncated to its last lines, which is also the
59 // right half to keep: it's the end of the newest message.
60 const rows: ReactElement[] = [];
61 let used = 0;
62 for (let i = turns.length - 1; i >= 0 && used < body; i--) {
63 const turn = turns[i];
64 if (!turn) continue;
65 const user = turn.role === 'user';
66 const all = wrap(turn.content, Math.max(8, width - 2));
67
68 // A blank row separates turns, but only when there's room for one. It goes
69 // *below* the block: turns are prepended, so the separator belongs between
70 // this turn and the newer one already below it — putting it on top would
71 // leave a stray blank line above the oldest visible turn instead.
72 const gap = rows.length > 0 ? 1 : 0;
73 const room = body - used - gap;
74 if (room <= 0) break;
75 const lines = all.length > room ? all.slice(all.length - room) : all;
76
77 rows.unshift(html`
78 <${Box} key=${i} flexDirection="column" marginBottom=${gap}>
79 ${lines.map((line, j) => html`
80 <${Text} key=${j} color=${user ? 'cyan' : undefined} dimColor=${!user}>
81 ${j === 0 && lines.length === all.length ? (user ? '› ' : ' ') : ' '}${line}
82 <//>
83 `)}
84 <//>
85 `);
86 used += lines.length + gap;
87 }
88
89 return html`
90 <${Box} flexDirection="column" height=${height}>
91 <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
92 ${rows.length === 0
93 ? html`
94 <${Box} flexDirection="column">
95 <${Text} dimColor>ask for a pattern — ^P<//>
96 ${hasKey
97 ? null
98 : html`<${Text} color="yellow" wrap="wrap">
99 no ANTHROPIC_API_KEY — set it in .env, or run \`ant auth login\`
100 <//>`}
101 <//>
102 `
103 : rows}
104 <//>
105
106 ${error ? html`<${Text} color="red" wrap="truncate-end">${error}<//>` : null}
107
108 ${/*
109 The composer is a prompt whether or not it has focus. A hint that reads
110 "^P to ask" describes the key rather than the thing, and it makes the
111 bottom of the pane change shape the moment you press it; a prompt that
112 is always there just becomes live. Dim until it is yours to type into,
113 so it still says which pane the keys are going to.
114 */ ''}
115 <${Box}>
116 ${pending
117 ? html`<${Text} color="magenta">✦ thinking…<//>`
118 : draft === null
119 ? html`<${Text} color=${focused ? 'cyan' : undefined} dimColor=${!focused}>› <//>`
120 : html`<${Text}><${Text} color="cyan">› <//>${draft}<${Text} inverse> <//><//>`}
121 <//>
122 <//>
123 `;
124});