@dotdrelle/wiki-manager 0.6.17

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.
@@ -0,0 +1,680 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { For, createEffect, createMemo, createSignal } from 'solid-js';
3
+ import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
+
5
+ const LEGACY_DONNA_ROLE = 'do' + 't';
6
+
7
+ function isDonnaRole(role: string) {
8
+ return role === 'donna' || role === LEGACY_DONNA_ROLE;
9
+ }
10
+
11
+ function roleLabel(role: string) {
12
+ if (role === 'user') return 'user';
13
+ if (role === 'command') return 'shell';
14
+ return 'donna';
15
+ }
16
+
17
+ function roleColor(role: string) {
18
+ if (role === 'user') return '#5DADE2';
19
+ if (role === 'command') return '#AAB7C4';
20
+ return '#8BD5CA';
21
+ }
22
+
23
+ function wrapLine(line: string, width: number) {
24
+ const max = Math.max(12, width);
25
+ if (line.length <= max) return [line];
26
+ const out = [];
27
+ let rest = line;
28
+ while (rest.length > max) {
29
+ const slice = rest.slice(0, max + 1);
30
+ const spaceAt = Math.max(slice.lastIndexOf(' '), slice.lastIndexOf('\t'));
31
+ const slashAt = slice.lastIndexOf('/');
32
+ const threshold = Math.floor(max * 0.45);
33
+ let index: number;
34
+ if (spaceAt > threshold && (slashAt < 0 || spaceAt <= slashAt)) {
35
+ index = spaceAt;
36
+ } else if (slashAt > threshold) {
37
+ index = slashAt + 1;
38
+ } else if (spaceAt > threshold) {
39
+ index = spaceAt;
40
+ } else {
41
+ index = max;
42
+ }
43
+ out.push(rest.slice(0, index).trimEnd());
44
+ rest = rest.slice(index).trimStart();
45
+ }
46
+ out.push(rest);
47
+ return out;
48
+ }
49
+
50
+ type Segment = { text: string; color: string; width?: number; bg?: string };
51
+ type RenderedLine = {
52
+ segments: Segment[];
53
+ status?: boolean;
54
+ statusLeft?: string;
55
+ statusRight?: string;
56
+ copyContent?: string;
57
+ };
58
+ const STATUS_COLUMN_GAP = 2;
59
+ type HelpCard = { title: string; text: string; example: string };
60
+
61
+ const HELP_CARDS: HelpCard[] = [
62
+ { title: '/new', text: 'Create/configure a workspace.', example: 'Ex: /new <name> [path]' },
63
+ { title: 'modify wikirc', text: 'Action: edit file.', example: 'File: .wikirc.yaml' },
64
+ { title: 'configure CME', text: 'Add type, URL, PAT, email.', example: 'Provide source credentials' },
65
+ { title: 'add MCP', text: 'Inspect and use MCP endpoints.', example: 'Ex: /mcp endpoints' },
66
+ { title: '/use', text: 'Load one workspace and its tools.', example: 'Ex: /use <workspace>' },
67
+ { title: 'llm call action', text: 'Ask to LLM.', example: 'Ex: ask to LLM to run an action' },
68
+ ];
69
+
70
+ function HelpCardPanel(props: { card: HelpCard; width: number }) {
71
+ return (
72
+ <box
73
+ width={props.width}
74
+ height={4}
75
+ flexDirection="column"
76
+ border={['left']}
77
+ borderStyle="heavy"
78
+ borderColor="#5DADE2"
79
+ backgroundColor="#111318"
80
+ paddingX={1}
81
+ overflow="hidden"
82
+ >
83
+ <text height={1} fg="#FBBF24">{props.card.title}</text>
84
+ <text height={1} fg="#D6DEE8">{props.card.text}</text>
85
+ <text height={1} fg="#7F8C8D">{props.card.example}</text>
86
+ </box>
87
+ );
88
+ }
89
+
90
+ function WelcomeHelpPanels(props: { width: number }) {
91
+ const cardWidth = Math.max(24, Math.floor((props.width - 6) / 2));
92
+ const rows = [
93
+ HELP_CARDS.slice(0, 2),
94
+ HELP_CARDS.slice(2, 4),
95
+ HELP_CARDS.slice(4, 6),
96
+ ];
97
+
98
+ return (
99
+ <box flexGrow={1} flexDirection="column" padding={1} overflow="hidden">
100
+ <text height={1} fg="#8BD5CA">Orchestrator agent ready.</text>
101
+ <text height={1} />
102
+ <text height={1} fg="#7F8C8D">Quick help</text>
103
+ <text height={1} />
104
+ <For each={rows}>
105
+ {(row) => (
106
+ <box height={5} flexDirection="row" gap={2} overflow="hidden">
107
+ <For each={row}>
108
+ {(card) => <HelpCardPanel card={card} width={cardWidth} />}
109
+ </For>
110
+ </box>
111
+ )}
112
+ </For>
113
+ <text height={1} fg="#7F8C8D">Load a workspace with /use &lt;workspace&gt;, then chat or use commands.</text>
114
+ <text height={1} fg="#7F8C8D">Type /help for all commands.</text>
115
+ </box>
116
+ );
117
+ }
118
+
119
+ const COPY_BTN = ' [ copy ]';
120
+
121
+ function messageHeaderSegments(role: string, columns: number): Segment[] {
122
+ const label = `[${roleLabel(role)}]`;
123
+ const left = '── ';
124
+ const rightLength = Math.max(2, columns - left.length - label.length - 1 - COPY_BTN.length);
125
+ return [
126
+ { text: left, color: '#4B5563' },
127
+ { text: label, color: roleColor(role) },
128
+ { text: ' ' + '─'.repeat(rightLength), color: '#4B5563' },
129
+ ];
130
+ }
131
+
132
+ // Split " /cmd [<arg>...] description" into two segments.
133
+ // Uses non-greedy to stop at the first double-space separator.
134
+ function splitCmdDesc(line: string): [string, string] | null {
135
+ const m = line.match(/^(\s*\/\S+(?:\s+\S+)*?)\s{2,}(.+)$/);
136
+ return m ? [m[1], m[2]] : null;
137
+ }
138
+
139
+ function helpSegments(line: string, columns: number): Segment[] | null {
140
+ const parts = helpCommandParts(line);
141
+ if (!parts) return null;
142
+ const commandWidth = Math.max(14, Math.min(22, Math.floor(columns * 0.27)));
143
+ const descriptionWidth = Math.max(12, Math.min(24, Math.floor(columns * 0.31)));
144
+ const secondCommandWidth = Math.max(12, Math.min(20, Math.floor(columns * 0.24)));
145
+ return [
146
+ { text: parts[0] ?? '', color: '#FBBF24', width: commandWidth },
147
+ { text: parts[1] ?? '', color: '#FFFFFF', width: parts[2] ? descriptionWidth : undefined },
148
+ ...(parts[2] ? [{ text: parts[2], color: '#FBBF24', width: secondCommandWidth }] : []),
149
+ ...(parts[3] ? [{ text: parts[3], color: '#FFFFFF' }] : []),
150
+ ];
151
+ }
152
+
153
+ function segmentsForLine(line: string, role: string, columns: number): Segment[] {
154
+ const help = role === 'command' ? helpSegments(line, columns) : null;
155
+ if (help) return help;
156
+ const keyValue = keyValueParts(line);
157
+ if (keyValue) {
158
+ const segs: Segment[] = [];
159
+ if (keyValue.prefix) segs.push({ text: keyValue.prefix, color: '#D6DEE8' });
160
+ segs.push({ text: keyValue.key, color: '#8BD5CA' });
161
+ segs.push({ text: keyValue.value, color: '#FFFFFF' });
162
+ return segs;
163
+ }
164
+ const cmdDesc = splitCmdDesc(line);
165
+ if (cmdDesc) {
166
+ return [
167
+ { text: cmdDesc[0], color: '#FBBF24' },
168
+ { text: ' ' + cmdDesc[1], color: '#FFFFFF' },
169
+ ];
170
+ }
171
+ return [{ text: line || ' ', color: colorForRenderedLine(line, role) }];
172
+ }
173
+
174
+ function isMarkdownTableRow(line: string) {
175
+ const trimmed = line.trim();
176
+ return trimmed.includes('|') && !/^(`{2,3}|~{2,3})/.test(trimmed);
177
+ }
178
+
179
+ function isMarkdownTableSeparator(line: string) {
180
+ return /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
181
+ }
182
+
183
+ function parseMarkdownTableRow(line: string) {
184
+ let text = line.trim();
185
+ if (text.startsWith('|')) text = text.slice(1);
186
+ if (text.endsWith('|')) text = text.slice(0, -1);
187
+ return text.split('|').map((cell) => renderPlainMarkdown(cell.trim()));
188
+ }
189
+
190
+ function wrapCellText(text: string, width: number) {
191
+ return wrapLine(text || ' ', Math.max(4, width));
192
+ }
193
+
194
+ function tableCellColor(text: string, header: boolean) {
195
+ if (header) return '#FBBF24';
196
+ if (/[❌✗]/.test(text)) return '#F38BA8';
197
+ if (/[⚠!]/.test(text)) return '#FBBF24';
198
+ if (/[✅✓]/.test(text)) return '#8BD5CA';
199
+ return '#D6DEE8';
200
+ }
201
+
202
+ function tableColumnWidths(rows: string[][], columns: number) {
203
+ // rows is already normalized (uniform length) by the caller
204
+ const columnCount = rows[0]?.length ?? 1;
205
+ const separatorWidth = Math.max(0, columnCount - 1) * 3;
206
+ const available = Math.max(columnCount * 5, columns - separatorWidth);
207
+ const natural = Array.from({ length: columnCount }, (_, i) =>
208
+ rows.reduce((m, row) => Math.max(m, row[i].length), 4),
209
+ );
210
+ const totalNatural = natural.reduce((sum, width) => sum + width, 0);
211
+ if (totalNatural <= available) return natural;
212
+
213
+ const minWidth = columnCount >= 5 ? 6 : 8;
214
+ let remaining = Math.max(columnCount * minWidth, available);
215
+ const widths = Array.from({ length: columnCount }, () => minWidth);
216
+ remaining -= widths.reduce((sum, width) => sum + width, 0);
217
+ const extraNatural = natural.map((width) => Math.max(0, width - minWidth));
218
+ let extraTotal = extraNatural.reduce((sum, width) => sum + width, 0);
219
+ for (let i = 0; i < columnCount && remaining > 0 && extraTotal > 0; i += 1) {
220
+ const add = Math.min(extraNatural[i], Math.floor((extraNatural[i] / extraTotal) * remaining));
221
+ widths[i] += add;
222
+ remaining -= add;
223
+ }
224
+ for (let i = 0; remaining > 0; i = (i + 1) % columnCount) {
225
+ widths[i] += 1;
226
+ remaining -= 1;
227
+ }
228
+ return widths;
229
+ }
230
+
231
+ function renderMarkdownTable(tableLines: string[], columns: number): RenderedLine[] {
232
+ const rows = tableLines
233
+ .filter((line) => !isMarkdownTableSeparator(line))
234
+ .map(parseMarkdownTableRow)
235
+ .filter((row) => row.length > 0);
236
+ if (rows.length === 0) return [];
237
+
238
+ const columnCount = rows.reduce((m, row) => Math.max(m, row.length), 1);
239
+ const normalized = rows.map((row) => Array.from({ length: columnCount }, (_, i) => row[i] ?? ''));
240
+ const widths = tableColumnWidths(normalized, columns);
241
+ const output: RenderedLine[] = [];
242
+
243
+ normalized.forEach((row, rowIndex) => {
244
+ const wrapped = row.map((cell, index) => wrapCellText(cell, widths[index]));
245
+ const height = wrapped.reduce((m, cell) => Math.max(m, cell.length), 1);
246
+ for (let lineIndex = 0; lineIndex < height; lineIndex += 1) {
247
+ const segments: Segment[] = [];
248
+ row.forEach((cell, cellIndex) => {
249
+ if (cellIndex > 0) segments.push({ text: ' │ ', color: '#4B5563' });
250
+ const piece = wrapped[cellIndex][lineIndex] ?? '';
251
+ segments.push({
252
+ text: piece.slice(0, widths[cellIndex]).padEnd(widths[cellIndex]),
253
+ color: tableCellColor(cell, rowIndex === 0),
254
+ });
255
+ });
256
+ output.push({ segments });
257
+ }
258
+ if (rowIndex === 0 && normalized.length > 1) {
259
+ output.push({
260
+ segments: widths.flatMap((width, index) => [
261
+ ...(index > 0 ? [{ text: '─┼─', color: '#4B5563' }] : []),
262
+ { text: '─'.repeat(width), color: '#4B5563' },
263
+ ]),
264
+ });
265
+ }
266
+ });
267
+ return output;
268
+ }
269
+
270
+ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, role: string, columns: number): RenderedLine[] {
271
+ const output: RenderedLine[] = [];
272
+ for (let index = 0; index < lines.length; index += 1) {
273
+ const { text, isCode } = lines[index];
274
+ if (isCode) {
275
+ output.push(...wrapLine(text || ' ', columns).map((piece) => ({
276
+ segments: [{ text: piece || ' ', color: '#D6DEE8', bg: '#1A2235' }],
277
+ })));
278
+ continue;
279
+ }
280
+
281
+ if (isMarkdownTableRow(text) && isMarkdownTableSeparator(lines[index + 1]?.text ?? '')) {
282
+ const tableLines = [text, lines[index + 1].text];
283
+ let next = index + 2;
284
+ while (next < lines.length && !lines[next].isCode && isMarkdownTableRow(lines[next].text)) {
285
+ tableLines.push(lines[next].text);
286
+ next += 1;
287
+ }
288
+ index = next - 1;
289
+ output.push(...renderMarkdownTable(tableLines, columns));
290
+ continue;
291
+ }
292
+
293
+ const rendered = renderPlainMarkdown(text);
294
+ const isCmdDesc = splitCmdDesc(rendered) !== null;
295
+ const fallback = isCmdDesc ? '#FFFFFF' : colorForRenderedLine(rendered, role);
296
+ output.push(...wrapLine(rendered, columns).map((piece, idx) => ({
297
+ segments: idx === 0
298
+ ? segmentsForLine(piece, role, columns)
299
+ : [{ text: piece || ' ', color: fallback }],
300
+ })));
301
+ }
302
+ return output;
303
+ }
304
+
305
+ function isStatusOutput(message: { role: string; content: string }) {
306
+ const content = String(message.content ?? '');
307
+ return message.role === 'command'
308
+ && content.startsWith('Workspace')
309
+ && content.includes('Config')
310
+ && content.includes('MCP');
311
+ }
312
+
313
+ function statusTextColor(value: string) {
314
+ return /^[A-Za-z].*$/.test(value.trimStart()) && !/^\s/.test(value) ? '#D6DEE8' : '#AAB7C4';
315
+ }
316
+
317
+ function statusValueColor(value: string) {
318
+ const text = String(value ?? '').toLowerCase();
319
+ if (/\b(running|connected|configured|ok)\b/.test(text)) return '#8BD5CA';
320
+ if (/\b(missing|failed|error|cancelled|canceled)\b/.test(text)) return '#F38BA8';
321
+ return '#FFFFFF';
322
+ }
323
+
324
+ function statusSegments(value: string): Segment[] {
325
+ const text = value || ' ';
326
+ const trimmed = text.trim();
327
+ if (trimmed && !text.startsWith(' ') && !trimmed.includes(':') && !/^[●◐○-]/.test(trimmed)) {
328
+ return [{ text, color: '#FBBF24' }];
329
+ }
330
+ if (trimmed && !text.startsWith(' ') && /^[A-Za-z].*:\s+/.test(trimmed)) {
331
+ const index = text.indexOf(':');
332
+ return [
333
+ { text: text.slice(0, index + 1), color: '#FBBF24' },
334
+ { text: text.slice(index + 1), color: '#AAB7C4' },
335
+ ];
336
+ }
337
+ if (/^[●◐○]/.test(trimmed)) {
338
+ return [{ text, color: colorForRenderedLine(trimmed, 'command') }];
339
+ }
340
+ const keyValue = keyValueParts(text);
341
+ if (keyValue) {
342
+ const segs: Segment[] = [];
343
+ if (keyValue.prefix) segs.push({ text: keyValue.prefix, color: '#AAB7C4' });
344
+ segs.push({ text: keyValue.key, color: '#8BD5CA' });
345
+ segs.push({ text: keyValue.value, color: statusValueColor(keyValue.value) });
346
+ return segs;
347
+ }
348
+ return [{ text, color: statusTextColor(text) }];
349
+ }
350
+
351
+ function statusColumns(line: string): { left: string; right: string } {
352
+ if (line.includes('\t')) {
353
+ const [left, ...rest] = line.split('\t');
354
+ return { left: left || ' ', right: rest.join('\t') };
355
+ }
356
+ const left = line.trimEnd();
357
+ const right = '';
358
+ return { left: left || ' ', right };
359
+ }
360
+
361
+ function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
362
+ return messages.flatMap((message) => {
363
+ const raw = String(message.content || '');
364
+ if (isStatusOutput(message)) {
365
+ return [
366
+ { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
367
+ ...raw.split('\n').map((line) => {
368
+ const { left, right } = statusColumns(line || ' ');
369
+ return { status: true, statusLeft: left, statusRight: right, segments: [] };
370
+ }),
371
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
372
+ ];
373
+ }
374
+ let inFence = false;
375
+ const lines: Array<{ text: string; isCode: boolean }> = [];
376
+ for (const line of raw.split('\n')) {
377
+ if (/^(`{2,3}|~{2,3})/.test(line)) { inFence = !inFence; continue; }
378
+ lines.push({ text: line, isCode: inFence });
379
+ }
380
+ return [
381
+ { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
382
+ ...renderMarkdownLines(lines, message.role, columns),
383
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
384
+ ];
385
+ });
386
+ }
387
+
388
+ export function ConversationView(props: {
389
+ messages: Array<{ role: string; content: string }>;
390
+ rows: number;
391
+ columns: number;
392
+ scroll: number;
393
+ onScroll: (delta: number) => void;
394
+ spinnerFrame: string;
395
+ onCopy?: (content: string) => void;
396
+ }) {
397
+ const allLines = createMemo(() => conversationLines(props.messages, props.columns));
398
+ const visibleLines = () => {
399
+ const lines = allLines();
400
+ const rows = Math.max(1, props.rows - 1);
401
+ const maxScroll = Math.max(0, lines.length - rows);
402
+ const scroll = Math.min(props.scroll, maxScroll);
403
+ const end = lines.length - scroll;
404
+ const start = Math.max(0, end - rows);
405
+ return lines.slice(start, end);
406
+ };
407
+ const scrollHint = () => {
408
+ const lines = allLines();
409
+ const rows = Math.max(1, props.rows - 1);
410
+ const maxScroll = Math.max(0, lines.length - rows);
411
+ const scroll = Math.min(props.scroll, maxScroll);
412
+ if (maxScroll === 0) return '';
413
+ if (scroll === 0) return `↑ ${maxScroll} lines`;
414
+ if (scroll === maxScroll) return `↓ ${maxScroll} lines`;
415
+ return `↑ ${maxScroll - scroll} ↓ ${scroll}`;
416
+ };
417
+
418
+ return (
419
+ <box
420
+ flexGrow={1}
421
+ flexDirection="column"
422
+ padding={1}
423
+ overflow="hidden"
424
+ onMouseScroll={(event: any) => {
425
+ const direction = event.scroll?.direction;
426
+ if (direction === 'up') props.onScroll(3);
427
+ if (direction === 'down') props.onScroll(-3);
428
+ event.preventDefault?.();
429
+ event.stopPropagation?.();
430
+ }}
431
+ >
432
+ <text height={1} fg="#7F8C8D">{scrollHint()}</text>
433
+ <For each={visibleLines()}>
434
+ {(line) => (
435
+ line.copyContent !== undefined ? (
436
+ <box height={1} flexDirection="row" overflow="hidden">
437
+ <For each={line.segments}>
438
+ {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
439
+ </For>
440
+ <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
441
+ </box>
442
+ ) : line.status ? (
443
+ <box height={1} flexDirection="row" gap={2} overflow="hidden">
444
+ <box
445
+ height={1}
446
+ width={Math.max(18, Math.floor((props.columns - STATUS_COLUMN_GAP) / 2))}
447
+ flexDirection="row"
448
+ border={['left']}
449
+ borderStyle="heavy"
450
+ borderColor="#5DADE2"
451
+ backgroundColor="#111318"
452
+ paddingX={1}
453
+ overflow="hidden"
454
+ >
455
+ <For each={statusSegments(line.statusLeft ?? '')}>
456
+ {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
457
+ </For>
458
+ </box>
459
+ {line.statusRight ? (
460
+ <box
461
+ height={1}
462
+ width={Math.max(18, Math.floor((props.columns - STATUS_COLUMN_GAP) / 2))}
463
+ flexDirection="row"
464
+ border={['left']}
465
+ borderStyle="heavy"
466
+ borderColor="#5DADE2"
467
+ backgroundColor="#111318"
468
+ paddingX={1}
469
+ overflow="hidden"
470
+ >
471
+ <For each={statusSegments(line.statusRight)}>
472
+ {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
473
+ </For>
474
+ </box>
475
+ ) : null}
476
+ </box>
477
+ ) : (
478
+ <box height={1} flexDirection="row" overflow="hidden">
479
+ <For each={line.segments}>
480
+ {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
481
+ </For>
482
+ </box>
483
+ )
484
+ )}
485
+ </For>
486
+ </box>
487
+ );
488
+ }
489
+
490
+ export function ChatInput(props: {
491
+ width: number;
492
+ prompt: string;
493
+ value: string;
494
+ busy: boolean;
495
+ chatMode: boolean;
496
+ focused: boolean;
497
+ spinnerFrame: string;
498
+ onInput: (value: string) => void;
499
+ onSubmit: (value?: string) => void;
500
+ onHeightChange: (height: number) => void;
501
+ }) {
502
+ let containerRef: any;
503
+ let textareaRef: any;
504
+ const minRows = 1;
505
+ const maxRows = 5;
506
+ const [textareaRows, setTextareaRows] = createSignal(minRows);
507
+ const idleColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
508
+ const promptText = () => props.busy ? `${props.spinnerFrame} ` : props.prompt;
509
+ const boxHeight = () => textareaRows() + 2;
510
+ const textareaColumns = () => {
511
+ const measured = Number(textareaRef?.width ?? 0);
512
+ if (Number.isFinite(measured) && measured > 0) return Math.max(8, measured - 1);
513
+ return Math.max(8, props.width - promptText().length - 7);
514
+ };
515
+ const estimatedVisualRows = (value: string) => {
516
+ const columns = textareaColumns();
517
+ return Math.max(1, value.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / columns)), 0));
518
+ };
519
+ const measuredVisualRows = (value: string) => {
520
+ const virtualRows = Number(textareaRef?.virtualLineCount ?? 0);
521
+ const logicalRows = Number(textareaRef?.lineCount ?? 0);
522
+ const scrollRows = Number(textareaRef?.scrollHeight ?? 0);
523
+ return Math.max(
524
+ estimatedVisualRows(value),
525
+ Number.isFinite(virtualRows) ? virtualRows : 0,
526
+ Number.isFinite(logicalRows) ? logicalRows : 0,
527
+ Number.isFinite(scrollRows) ? scrollRows : 0,
528
+ );
529
+ };
530
+ const applyHeight = (rows: number) => {
531
+ const height = rows + 2;
532
+ if (textareaRef) textareaRef.height = rows;
533
+ if (containerRef) containerRef.height = height;
534
+ props.onHeightChange(height);
535
+ };
536
+ const updateRows = (value = String(textareaRef?.plainText ?? props.value ?? '')) => {
537
+ const rows = Math.min(maxRows, Math.max(minRows, measuredVisualRows(value)));
538
+ setTextareaRows(rows);
539
+ applyHeight(rows);
540
+ };
541
+ const syncTextareaValue = (value: string) => {
542
+ const current = String(textareaRef?.plainText ?? '');
543
+ if (!textareaRef || current === value) return;
544
+ if (value === '') textareaRef.clear?.();
545
+ else textareaRef.setText?.(value);
546
+ try {
547
+ textareaRef.cursorOffset = value.length;
548
+ } catch {
549
+ // Some renderable states reject cursor movement while layout is settling.
550
+ }
551
+ updateRows(value);
552
+ queueMicrotask(() => updateRows(value));
553
+ };
554
+ const handleContentChange = () => {
555
+ const value = String(textareaRef?.plainText ?? '');
556
+ props.onInput(value);
557
+ updateRows(value);
558
+ queueMicrotask(() => updateRows(value));
559
+ };
560
+ const submitCurrentValue = () => {
561
+ props.onSubmit(String(textareaRef?.plainText ?? props.value ?? ''));
562
+ };
563
+
564
+ createEffect(() => {
565
+ props.onHeightChange(boxHeight());
566
+ });
567
+
568
+ createEffect(() => {
569
+ syncTextareaValue(props.value);
570
+ });
571
+
572
+ createEffect(() => {
573
+ props.width;
574
+ props.prompt;
575
+ props.spinnerFrame;
576
+ queueMicrotask(() => updateRows());
577
+ });
578
+
579
+ return (
580
+ <box
581
+ ref={containerRef}
582
+ height={boxHeight()}
583
+ paddingX={1}
584
+ flexDirection="row"
585
+ alignItems="flex-start"
586
+ border
587
+ borderStyle="single"
588
+ borderColor={props.busy ? '#FBBF24' : idleColor()}
589
+ >
590
+ <text height={1} fg={props.busy ? '#FBBF24' : idleColor()}>{promptText()}</text>
591
+ <textarea
592
+ ref={textareaRef}
593
+ flexGrow={1}
594
+ height={textareaRows()}
595
+ focused={props.focused && !props.busy}
596
+ initialValue={props.value}
597
+ wrapMode="word"
598
+ placeholder={props.busy ? 'Thinking' : 'Type a message or /command'}
599
+ keyBindings={[
600
+ { name: 'return', action: 'submit' },
601
+ { name: 'kpenter', action: 'submit' },
602
+ { name: 'linefeed', action: 'newline' },
603
+ { name: 'return', shift: true, action: 'newline' },
604
+ { name: 'kpenter', shift: true, action: 'newline' },
605
+ { name: 'linefeed', shift: true, action: 'newline' },
606
+ ]}
607
+ onSubmit={submitCurrentValue}
608
+ onContentChange={handleContentChange}
609
+ />
610
+ <text width={1}> </text>
611
+ </box>
612
+ );
613
+ }
614
+
615
+ export function LeftPane(props: {
616
+ width: number;
617
+ title: string;
618
+ statusLine: string;
619
+ hintLine?: string | null;
620
+ showWelcome: boolean;
621
+ messages: Array<{ role: string; content: string }>;
622
+ prompt: string;
623
+ input: string;
624
+ busy: boolean;
625
+ chatMode: boolean;
626
+ chatFocused: boolean;
627
+ setInput: (value: string) => void;
628
+ submit: (value?: string) => void;
629
+ conversationRows: number;
630
+ conversationColumns: number;
631
+ conversationScroll: number;
632
+ scrollConversation: (delta: number) => void;
633
+ spinnerFrame: string;
634
+ onInputHeightChange: (height: number) => void;
635
+ onCopy?: (content: string) => void;
636
+ }) {
637
+ const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
638
+ const modeLabel = () => props.chatMode ? 'CHAT MODE direct LLM, no tools' : 'AGENTIC MODE LangGraph + MCP tools';
639
+ return (
640
+ <box width={props.width} height="100%" flexDirection="column" padding={1} overflow="hidden">
641
+ <box height={3} flexDirection="column">
642
+ <box height={1} flexDirection="row" backgroundColor={modeColor()} paddingX={1}>
643
+ <text fg="#0B1020">{modeLabel()}</text>
644
+ </box>
645
+ <box height={1} flexDirection="row">
646
+ <text fg="#D6DEE8">{props.title}</text>
647
+ <text fg="#7F8C8D"> {props.statusLine}</text>
648
+ </box>
649
+ <box height={1} flexDirection="row">
650
+ {props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
651
+ </box>
652
+ </box>
653
+ {props.showWelcome ? (
654
+ <WelcomeHelpPanels width={props.conversationColumns} />
655
+ ) : (
656
+ <ConversationView
657
+ messages={props.messages}
658
+ rows={props.conversationRows}
659
+ columns={props.conversationColumns}
660
+ scroll={props.conversationScroll}
661
+ onScroll={props.scrollConversation}
662
+ spinnerFrame={props.spinnerFrame}
663
+ onCopy={props.onCopy}
664
+ />
665
+ )}
666
+ <ChatInput
667
+ width={props.width}
668
+ prompt={props.prompt}
669
+ value={props.input}
670
+ busy={props.busy}
671
+ chatMode={props.chatMode}
672
+ focused={props.chatFocused}
673
+ spinnerFrame={props.spinnerFrame}
674
+ onInput={props.setInput}
675
+ onSubmit={props.submit}
676
+ onHeightChange={props.onInputHeightChange}
677
+ />
678
+ </box>
679
+ );
680
+ }