| 1 | import { Box, Text } from 'ink'; |
| 2 | import { memo } from 'react'; |
| 3 | import type { ReactElement } from 'react'; |
| 4 | import { html } from './html.ts'; |
| 5 | import 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 | */ |
| 15 | function 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 | |
| 29 | export 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 | |
| 43 | export 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 | }); |