mu-coding 0.8.0 → 0.9.0

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.
Files changed (41) hide show
  1. package/package.json +4 -4
  2. package/src/cli/install.ts +18 -3
  3. package/src/plugin.ts +33 -5
  4. package/src/runtime/createRegistry.test.ts +4 -3
  5. package/src/runtime/createRegistry.ts +34 -2
  6. package/src/runtime/fileMentionProvider.ts +116 -0
  7. package/src/runtime/pluginLoader.ts +37 -6
  8. package/src/tui/channel/tuiChannel.ts +14 -1
  9. package/src/tui/chat/useAbort.ts +5 -0
  10. package/src/tui/chat/useChat.ts +7 -0
  11. package/src/tui/chat/useChatPanel.ts +24 -3
  12. package/src/tui/chat/useChatSession.ts +105 -7
  13. package/src/tui/chat/useModels.ts +25 -1
  14. package/src/tui/chat/useSessionPersistence.ts +27 -11
  15. package/src/tui/chat/useStatusSegments.ts +26 -6
  16. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  17. package/src/tui/components/chat/ChatPanel.tsx +16 -1
  18. package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
  19. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  20. package/src/tui/components/messages/EditOutput.tsx +11 -5
  21. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  22. package/src/tui/components/messages/ToolHeader.tsx +6 -4
  23. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  24. package/src/tui/components/messages/assistantMessage.tsx +43 -10
  25. package/src/tui/components/messages/markdown.tsx +402 -0
  26. package/src/tui/components/messages/reasoningBlock.tsx +8 -6
  27. package/src/tui/components/messages/streamingOutput.tsx +1 -1
  28. package/src/tui/components/messages/toolCallBlock.tsx +2 -2
  29. package/src/tui/components/messages/userMessage.tsx +3 -3
  30. package/src/tui/components/primitives/toast.tsx +38 -7
  31. package/src/tui/components/statusBar.tsx +24 -15
  32. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  33. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  34. package/src/tui/input/InputBoxView.tsx +71 -15
  35. package/src/tui/input/commands.ts +5 -0
  36. package/src/tui/input/useInputBox.ts +29 -3
  37. package/src/tui/input/useInputHandler.ts +1 -0
  38. package/src/tui/input/useMentionPicker.ts +26 -14
  39. package/src/tui/renderApp.tsx +29 -8
  40. package/src/tui/theme/presets.ts +12 -1
  41. package/src/tui/theme/types.ts +22 -0
@@ -0,0 +1,402 @@
1
+ import { Box, Text } from 'ink';
2
+ import type React from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
4
+ import type { Theme } from '../../theme/types';
5
+
6
+ /**
7
+ * Lightweight Markdown renderer for assistant messages. Intentionally
8
+ * pragmatic — covers headings, lists, tables, blockquotes, code blocks,
9
+ * and the common inline tokens (bold, italic, inline code, links).
10
+ *
11
+ * Implementation is two-pass:
12
+ * - `parseBlocks` splits the raw text into a list of typed blocks
13
+ * - each block has its own React renderer that delegates inline
14
+ * formatting to `renderInline` for paragraph / list / heading content.
15
+ *
16
+ * No external markdown dependency: the renderer must work in any host
17
+ * shipping `mu-coding` without forcing them to ship a parser.
18
+ */
19
+
20
+ // ─── Block model ──────────────────────────────────────────────────────────────
21
+
22
+ type Block =
23
+ | { type: 'heading'; level: 1 | 2 | 3; text: string }
24
+ | { type: 'paragraph'; text: string }
25
+ | { type: 'list'; ordered: boolean; items: string[] }
26
+ | { type: 'code'; lang: string; lines: string[] }
27
+ | { type: 'quote'; lines: string[] }
28
+ | { type: 'table'; header: string[]; rows: string[][] }
29
+ | { type: 'hr' };
30
+
31
+ const FENCE = /^```(.*)$/;
32
+ const HEADING = /^(#{1,3})\s+(.*)$/;
33
+ const UL_ITEM = /^(\s*)[-*+]\s+(.*)$/;
34
+ const OL_ITEM = /^(\s*)\d+\.\s+(.*)$/;
35
+ const QUOTE = /^>\s?(.*)$/;
36
+ const HR = /^\s*(---|\*\*\*|___)\s*$/;
37
+ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
38
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
39
+
40
+ function splitTableRow(line: string): string[] {
41
+ // Trim outer pipes then split. Handles `| a | b |` and `a | b`.
42
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
43
+ return trimmed.split('|').map((c) => c.trim());
44
+ }
45
+
46
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: block parsing is dispatch-heavy
47
+ function parseBlocks(input: string): Block[] {
48
+ const lines = input.split('\n');
49
+ const blocks: Block[] = [];
50
+ let i = 0;
51
+
52
+ while (i < lines.length) {
53
+ const line = lines[i];
54
+
55
+ // Code fence — capture until matching closing fence (or EOF).
56
+ const fence = line.match(FENCE);
57
+ if (fence) {
58
+ const lang = fence[1].trim();
59
+ const codeLines: string[] = [];
60
+ i++;
61
+ while (i < lines.length && !FENCE.test(lines[i])) {
62
+ codeLines.push(lines[i]);
63
+ i++;
64
+ }
65
+ if (i < lines.length) i++; // skip closing fence
66
+ blocks.push({ type: 'code', lang, lines: codeLines });
67
+ continue;
68
+ }
69
+
70
+ if (line.trim() === '') {
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ if (HR.test(line)) {
76
+ blocks.push({ type: 'hr' });
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ const heading = line.match(HEADING);
82
+ if (heading) {
83
+ blocks.push({ type: 'heading', level: heading[1].length as 1 | 2 | 3, text: heading[2] });
84
+ i++;
85
+ continue;
86
+ }
87
+
88
+ // Quote — consume consecutive `> ` lines.
89
+ if (QUOTE.test(line)) {
90
+ const quoteLines: string[] = [];
91
+ while (i < lines.length && QUOTE.test(lines[i])) {
92
+ quoteLines.push((lines[i].match(QUOTE) as RegExpMatchArray)[1]);
93
+ i++;
94
+ }
95
+ blocks.push({ type: 'quote', lines: quoteLines });
96
+ continue;
97
+ }
98
+
99
+ // Table — header row, separator row, then body rows.
100
+ if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
101
+ const header = splitTableRow(line);
102
+ i += 2; // skip header + separator
103
+ const rows: string[][] = [];
104
+ while (i < lines.length && TABLE_ROW.test(lines[i])) {
105
+ rows.push(splitTableRow(lines[i]));
106
+ i++;
107
+ }
108
+ blocks.push({ type: 'table', header, rows });
109
+ continue;
110
+ }
111
+
112
+ // List — ordered or unordered, contiguous items only.
113
+ const ulMatch = line.match(UL_ITEM);
114
+ const olMatch = line.match(OL_ITEM);
115
+ if (ulMatch || olMatch) {
116
+ const ordered = !!olMatch;
117
+ const re = ordered ? OL_ITEM : UL_ITEM;
118
+ const items: string[] = [];
119
+ while (i < lines.length) {
120
+ const m = lines[i].match(re);
121
+ if (!m) break;
122
+ items.push(m[2]);
123
+ i++;
124
+ }
125
+ blocks.push({ type: 'list', ordered, items });
126
+ continue;
127
+ }
128
+
129
+ // Paragraph — contiguous non-empty, non-block lines.
130
+ const paraLines: string[] = [];
131
+ while (i < lines.length) {
132
+ const cur = lines[i];
133
+ if (cur.trim() === '') break;
134
+ if (FENCE.test(cur) || HEADING.test(cur) || QUOTE.test(cur) || HR.test(cur)) break;
135
+ if (UL_ITEM.test(cur) || OL_ITEM.test(cur)) break;
136
+ if (TABLE_ROW.test(cur) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) break;
137
+ paraLines.push(cur);
138
+ i++;
139
+ }
140
+ if (paraLines.length > 0) {
141
+ blocks.push({ type: 'paragraph', text: paraLines.join(' ') });
142
+ }
143
+ }
144
+
145
+ return blocks;
146
+ }
147
+
148
+ // ─── Inline rendering ─────────────────────────────────────────────────────────
149
+
150
+ interface InlineToken {
151
+ kind: 'text' | 'bold' | 'italic' | 'code' | 'link';
152
+ text: string;
153
+ href?: string;
154
+ }
155
+
156
+ const INLINE_PATTERNS: { kind: InlineToken['kind']; re: RegExp; capture: number; href?: number }[] = [
157
+ // Order matters: longer / more specific patterns first.
158
+ { kind: 'code', re: /`([^`\n]+)`/, capture: 1 },
159
+ { kind: 'link', re: /\[([^\]]+)\]\(([^)]+)\)/, capture: 1, href: 2 },
160
+ { kind: 'bold', re: /\*\*([^*\n]+)\*\*/, capture: 1 },
161
+ { kind: 'bold', re: /__([^_\n]+)__/, capture: 1 },
162
+ { kind: 'italic', re: /\*([^*\n]+)\*/, capture: 1 },
163
+ { kind: 'italic', re: /_([^_\n]+)_/, capture: 1 },
164
+ ];
165
+
166
+ function tokenizeInline(input: string): InlineToken[] {
167
+ const out: InlineToken[] = [];
168
+ let cursor = 0;
169
+ while (cursor < input.length) {
170
+ let bestIdx = -1;
171
+ let bestMatch: { kind: InlineToken['kind']; text: string; href?: string; raw: string } | null = null;
172
+ for (const p of INLINE_PATTERNS) {
173
+ const sub = input.slice(cursor);
174
+ const m = sub.match(p.re);
175
+ if (m && m.index !== undefined) {
176
+ if (bestIdx === -1 || m.index < bestIdx) {
177
+ bestIdx = m.index;
178
+ bestMatch = {
179
+ kind: p.kind,
180
+ text: m[p.capture],
181
+ href: p.href !== undefined ? m[p.href] : undefined,
182
+ raw: m[0],
183
+ };
184
+ }
185
+ }
186
+ }
187
+ if (!bestMatch || bestIdx === -1) {
188
+ out.push({ kind: 'text', text: input.slice(cursor) });
189
+ break;
190
+ }
191
+ if (bestIdx > 0) {
192
+ out.push({ kind: 'text', text: input.slice(cursor, cursor + bestIdx) });
193
+ }
194
+ out.push({ kind: bestMatch.kind, text: bestMatch.text, href: bestMatch.href });
195
+ cursor += bestIdx + bestMatch.raw.length;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ function renderInline(text: string, theme: Theme, baseColor?: string): React.ReactNode[] {
201
+ const tokens = tokenizeInline(text);
202
+ return tokens.map((tok, i) => {
203
+ const key = `${i}-${tok.kind}`;
204
+ if (tok.kind === 'text') {
205
+ return (
206
+ <Text key={key} color={baseColor}>
207
+ {tok.text}
208
+ </Text>
209
+ );
210
+ }
211
+ if (tok.kind === 'bold') {
212
+ return (
213
+ <Text key={key} color={baseColor} bold={true}>
214
+ {tok.text}
215
+ </Text>
216
+ );
217
+ }
218
+ if (tok.kind === 'italic') {
219
+ return (
220
+ <Text key={key} color={baseColor} italic={true}>
221
+ {tok.text}
222
+ </Text>
223
+ );
224
+ }
225
+ if (tok.kind === 'code') {
226
+ return (
227
+ <Text key={key} color={theme.markdown.codeText} backgroundColor={theme.markdown.codeBackground}>
228
+ {` ${tok.text} `}
229
+ </Text>
230
+ );
231
+ }
232
+ if (tok.kind === 'link') {
233
+ return (
234
+ <Text key={key} color={theme.markdown.link} underline={true}>
235
+ {tok.text}
236
+ {tok.href ? (
237
+ <Text color={theme.markdown.link} dimColor={true}>
238
+ {' '}
239
+ ({tok.href})
240
+ </Text>
241
+ ) : null}
242
+ </Text>
243
+ );
244
+ }
245
+ return null;
246
+ });
247
+ }
248
+
249
+ // ─── Block renderers ──────────────────────────────────────────────────────────
250
+
251
+ function HeadingBlock({ block, theme }: { block: Extract<Block, { type: 'heading' }>; theme: Theme }) {
252
+ const prefix = block.level === 1 ? '# ' : block.level === 2 ? '## ' : '### ';
253
+ return (
254
+ <Box marginBottom={1}>
255
+ <Text color={theme.markdown.heading} bold={block.level <= 2}>
256
+ {prefix}
257
+ {block.text}
258
+ </Text>
259
+ </Box>
260
+ );
261
+ }
262
+
263
+ function ParagraphBlock({
264
+ block,
265
+ theme,
266
+ color,
267
+ }: {
268
+ block: Extract<Block, { type: 'paragraph' }>;
269
+ theme: Theme;
270
+ color?: string;
271
+ }) {
272
+ return (
273
+ <Box marginBottom={1}>
274
+ <Text wrap="wrap" color={color}>
275
+ {renderInline(block.text, theme, color)}
276
+ </Text>
277
+ </Box>
278
+ );
279
+ }
280
+
281
+ function ListBlock({ block, theme, color }: { block: Extract<Block, { type: 'list' }>; theme: Theme; color?: string }) {
282
+ return (
283
+ <Box flexDirection="column" marginBottom={1}>
284
+ {block.items.map((item, idx) => {
285
+ const marker = block.ordered ? `${idx + 1}.` : '•';
286
+ return (
287
+ // biome-ignore lint/suspicious/noArrayIndexKey: list items have no stable id
288
+ <Box key={idx}>
289
+ <Text color={theme.markdown.bullet}>{` ${marker} `}</Text>
290
+ <Box flexShrink={1} flexGrow={1}>
291
+ <Text wrap="wrap" color={color}>
292
+ {renderInline(item, theme, color)}
293
+ </Text>
294
+ </Box>
295
+ </Box>
296
+ );
297
+ })}
298
+ </Box>
299
+ );
300
+ }
301
+
302
+ function CodeBlock({ block, theme }: { block: Extract<Block, { type: 'code' }>; theme: Theme }) {
303
+ return (
304
+ <Box flexDirection="column" marginBottom={1} paddingX={1} backgroundColor={theme.markdown.codeBlockBackground}>
305
+ {block.lang && (
306
+ <Text dimColor={true} color={theme.markdown.codeBlockText}>
307
+ {block.lang}
308
+ </Text>
309
+ )}
310
+ {block.lines.map((ln, i) => (
311
+ // biome-ignore lint/suspicious/noArrayIndexKey: code lines have no stable id and may repeat
312
+ <Text key={`${i}-${ln}`} color={theme.markdown.codeBlockText}>
313
+ {ln || ' '}
314
+ </Text>
315
+ ))}
316
+ </Box>
317
+ );
318
+ }
319
+
320
+ function QuoteBlock({ block, theme }: { block: Extract<Block, { type: 'quote' }>; theme: Theme }) {
321
+ return (
322
+ <Box flexDirection="column" marginBottom={1}>
323
+ {block.lines.map((ln, i) => (
324
+ <Box key={`${i}-${ln}`}>
325
+ <Text color={theme.markdown.blockquote}> │ </Text>
326
+ <Box flexShrink={1} flexGrow={1}>
327
+ <Text wrap="wrap" color={theme.markdown.blockquote} italic={true}>
328
+ {renderInline(ln, theme, theme.markdown.blockquote)}
329
+ </Text>
330
+ </Box>
331
+ </Box>
332
+ ))}
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ function TableBlock({ block, theme }: { block: Extract<Block, { type: 'table' }>; theme: Theme }) {
338
+ // Compute column widths based on the longest cell per column.
339
+ const colCount = Math.max(block.header.length, ...block.rows.map((r) => r.length));
340
+ const widths: number[] = new Array(colCount).fill(0);
341
+ for (let c = 0; c < colCount; c++) {
342
+ widths[c] = (block.header[c] ?? '').length;
343
+ for (const row of block.rows) {
344
+ widths[c] = Math.max(widths[c], (row[c] ?? '').length);
345
+ }
346
+ }
347
+ const renderRow = (cells: string[], bold: boolean, key: string) => (
348
+ <Box key={key}>
349
+ {Array.from({ length: colCount }, (_, c) => (
350
+ <Box key={c} marginRight={c === colCount - 1 ? 0 : 2}>
351
+ <Text bold={bold}>{(cells[c] ?? '').padEnd(widths[c])}</Text>
352
+ </Box>
353
+ ))}
354
+ </Box>
355
+ );
356
+ const sep = (
357
+ <Box>
358
+ {Array.from({ length: colCount }, (_, c) => (
359
+ <Box key={c} marginRight={c === colCount - 1 ? 0 : 2}>
360
+ <Text color={theme.markdown.tableBorder}>{'─'.repeat(widths[c])}</Text>
361
+ </Box>
362
+ ))}
363
+ </Box>
364
+ );
365
+ return (
366
+ <Box flexDirection="column" marginBottom={1}>
367
+ {renderRow(block.header, true, 'header')}
368
+ {sep}
369
+ {block.rows.map((r, i) => renderRow(r, false, `r-${i}`))}
370
+ </Box>
371
+ );
372
+ }
373
+
374
+ function HrBlock({ theme }: { theme: Theme }) {
375
+ return (
376
+ <Box marginBottom={1}>
377
+ <Text color={theme.markdown.tableBorder}>{'─'.repeat(40)}</Text>
378
+ </Box>
379
+ );
380
+ }
381
+
382
+ // ─── Public renderer ─────────────────────────────────────────────────────────
383
+
384
+ export function MarkdownContent({ content, color }: { content: string; color?: string }) {
385
+ const theme = useTheme();
386
+ const blocks = parseBlocks(content);
387
+ return (
388
+ <Box flexDirection="column">
389
+ {blocks.map((b, i) => {
390
+ const key = `${i}-${b.type}`;
391
+ if (b.type === 'heading') return <HeadingBlock key={key} block={b} theme={theme} />;
392
+ if (b.type === 'paragraph') return <ParagraphBlock key={key} block={b} theme={theme} color={color} />;
393
+ if (b.type === 'list') return <ListBlock key={key} block={b} theme={theme} color={color} />;
394
+ if (b.type === 'code') return <CodeBlock key={key} block={b} theme={theme} />;
395
+ if (b.type === 'quote') return <QuoteBlock key={key} block={b} theme={theme} />;
396
+ if (b.type === 'table') return <TableBlock key={key} block={b} theme={theme} />;
397
+ if (b.type === 'hr') return <HrBlock key={key} theme={theme} />;
398
+ return null;
399
+ })}
400
+ </Box>
401
+ );
402
+ }
@@ -4,12 +4,14 @@ import { useTheme } from '../../context/ThemeContext';
4
4
  export function ReasoningBlock({ reasoning }: { reasoning: string }) {
5
5
  const theme = useTheme();
6
6
  return (
7
- <Box flexDirection="column" marginTop={0} marginBottom={1}>
8
- <Text color={theme.reasoning.title} italic={true}>
9
- thinking
10
- </Text>
11
- <Text color={theme.reasoning.body} italic={true} wrap="wrap">
12
- {reasoning}
7
+ <Box marginBottom={0}>
8
+ <Text wrap="wrap">
9
+ <Text color={theme.reasoning.title} italic={true}>
10
+ thinking:{' '}
11
+ </Text>
12
+ <Text color={theme.reasoning.body} italic={true}>
13
+ {reasoning}
14
+ </Text>
13
15
  </Text>
14
16
  </Box>
15
17
  );
@@ -5,7 +5,7 @@ import { ReasoningBlock } from './reasoningBlock';
5
5
  export function StreamingOutput({ currentText, currentReasoning }: { currentText: string; currentReasoning: string }) {
6
6
  const theme = useTheme();
7
7
  return (
8
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
8
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
9
9
  {currentReasoning && <ReasoningBlock reasoning={currentReasoning} />}
10
10
  <Text wrap="wrap">
11
11
  {currentText}
@@ -47,7 +47,7 @@ export function ToolCallBlock({
47
47
  const argSummary = getArgSummary(args, hint);
48
48
 
49
49
  return (
50
- <Box flexDirection="column" flexShrink={0}>
50
+ <Box flexDirection="column" flexShrink={0} marginTop={1} marginBottom={1}>
51
51
  {!hasResult ? (
52
52
  <Box>
53
53
  <Text dimColor={true}>
@@ -114,7 +114,7 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
114
114
  </>
115
115
  )}
116
116
  </Text>
117
- <Box flexDirection="column" backgroundColor={theme.tool.previewBackground} padding={1} marginTop={1}>
117
+ <Box flexDirection="column" backgroundColor={theme.tool.previewBackground} paddingX={1} paddingY={0}>
118
118
  <Text color={theme.tool.previewText}>{preview}</Text>
119
119
  </Box>
120
120
  </Box>
@@ -11,7 +11,7 @@ export function UserMessage({ msg }: { msg: ChatMessage }) {
11
11
  <Box
12
12
  flexDirection="column"
13
13
  flexShrink={0}
14
- marginY={1}
14
+ marginBottom={1}
15
15
  backgroundColor={theme.user.background}
16
16
  paddingX={1}
17
17
  paddingY={1}
@@ -23,9 +23,9 @@ export function UserMessage({ msg }: { msg: ChatMessage }) {
23
23
  borderStyle="single"
24
24
  >
25
25
  {badge && (
26
- <Box marginBottom={1}>
26
+ <Box>
27
27
  <Text color={msg.display?.color} bold={true}>
28
- [{badge}]
28
+ {badge.charAt(0).toUpperCase() + badge.slice(1)}
29
29
  </Text>
30
30
  </Box>
31
31
  )}
@@ -1,5 +1,5 @@
1
1
  import { Box, Text, useInput, useStdout } from 'ink';
2
- import { useCallback, useState } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { useTheme } from '../../context/ThemeContext';
4
4
 
5
5
  export interface Toast {
@@ -8,22 +8,53 @@ export interface Toast {
8
8
  color?: string;
9
9
  }
10
10
 
11
+ const TOAST_TIMEOUT_MS = 60_000;
12
+
11
13
  let nextId = 0;
12
14
 
13
15
  export function useToast() {
14
16
  const [toasts, setToasts] = useState<Toast[]>([]);
15
-
16
- const show = useCallback((message: string, color?: string) => {
17
- const id = nextId++;
18
- setToasts((prev) => [...prev, { id, message, color }]);
19
- }, []);
17
+ const timersRef = useRef(new Map<number, ReturnType<typeof setTimeout>>());
20
18
 
21
19
  const dismiss = useCallback((id: number) => {
20
+ const timer = timersRef.current.get(id);
21
+ if (timer) {
22
+ clearTimeout(timer);
23
+ timersRef.current.delete(id);
24
+ }
22
25
  setToasts((prev) => prev.filter((t) => t.id !== id));
23
26
  }, []);
24
27
 
28
+ const show = useCallback(
29
+ (message: string, color?: string) => {
30
+ const id = nextId++;
31
+ setToasts((prev) => [...prev, { id, message, color }]);
32
+ const timer = setTimeout(() => dismiss(id), TOAST_TIMEOUT_MS);
33
+ timersRef.current.set(id, timer);
34
+ },
35
+ [dismiss],
36
+ );
37
+
25
38
  const dismissFirst = useCallback(() => {
26
- setToasts((prev) => prev.slice(1));
39
+ setToasts((prev) => {
40
+ const [first, ...rest] = prev;
41
+ if (first) {
42
+ const timer = timersRef.current.get(first.id);
43
+ if (timer) {
44
+ clearTimeout(timer);
45
+ timersRef.current.delete(first.id);
46
+ }
47
+ }
48
+ return rest;
49
+ });
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ const timers = timersRef.current;
54
+ return () => {
55
+ for (const timer of timers.values()) clearTimeout(timer);
56
+ timers.clear();
57
+ };
27
58
  }, []);
28
59
 
29
60
  return { toasts, show, dismiss, dismissFirst };
@@ -5,27 +5,36 @@ export interface StatusBarSegment {
5
5
  text: string;
6
6
  color?: string;
7
7
  dim?: boolean;
8
+ /** Pin to the left zone of the status bar. Defaults to right-aligned. */
9
+ align?: 'left' | 'right';
10
+ }
11
+
12
+ function renderZone(segments: StatusBarSegment[], separatorColor: string) {
13
+ return segments.map((seg, i) => (
14
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
15
+ <Box key={i}>
16
+ {i > 0 && (
17
+ <Text color={separatorColor} dimColor={true}>
18
+ {' '}
19
+ ·{' '}
20
+ </Text>
21
+ )}
22
+ <Text color={seg.color} dimColor={seg.dim}>
23
+ {seg.text}
24
+ </Text>
25
+ </Box>
26
+ ));
8
27
  }
9
28
 
10
29
  export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
11
30
  const theme = useTheme();
31
+ const left = segments.filter((s) => s.align === 'left');
32
+ const right = segments.filter((s) => s.align !== 'left');
12
33
  return (
13
- <Box flexShrink={0} paddingX={1} marginY={1}>
34
+ <Box flexShrink={0} paddingX={1} marginTop={1}>
35
+ <Box>{renderZone(left, theme.status.separator)}</Box>
14
36
  <Box justifyContent="flex-end" flexGrow={1}>
15
- {segments.map((seg, i) => (
16
- // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
17
- <Box key={i}>
18
- {i > 0 && (
19
- <Text color={theme.status.separator} dimColor={true}>
20
- {' '}
21
- ·{' '}
22
- </Text>
23
- )}
24
- <Text color={seg.color} dimColor={seg.dim}>
25
- {seg.text}
26
- </Text>
27
- </Box>
28
- ))}
37
+ {renderZone(right, theme.status.separator)}
29
38
  </Box>
30
39
  </Box>
31
40
  );
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Two-key emacs-style chord prefix.
3
+ *
4
+ * `useChordKeyboard` takes a prefix predicate (e.g. "Ctrl+X") and a map of
5
+ * follow-up handlers. Pressing the prefix arms a chord state for
6
+ * `timeoutMs` (default 1000); the next key event dispatches to the
7
+ * matching handler, or — if nothing matches before the timer fires — the
8
+ * chord is dropped silently.
9
+ *
10
+ * Integrates with Ink's `useInput` so it cooperates with the rest of the
11
+ * keyboard pipeline; keys consumed while armed are swallowed regardless of
12
+ * whether they matched a follow-up handler, so a stray `g` after `Ctrl+X`
13
+ * does not leak into the chat input.
14
+ */
15
+
16
+ import { type Key, useInput } from 'ink';
17
+ import { useEffect, useRef } from 'react';
18
+
19
+ export interface ChordKey {
20
+ /** Lower-case input character, when the press produced one. */
21
+ input: string;
22
+ /** Modifiers / arrow keys provided by Ink. */
23
+ key: Key;
24
+ }
25
+
26
+ export type ChordPredicate = (k: ChordKey) => boolean;
27
+ export type ChordHandler = () => void;
28
+
29
+ export interface ChordSpec {
30
+ /** Predicate matching the prefix (e.g. `({key, input}) => key.ctrl && input === 'x'`). */
31
+ prefix: ChordPredicate;
32
+ /**
33
+ * Follow-up handlers. The first matching predicate (by insertion order)
34
+ * runs; non-matching follow-ups still consume the key and clear the
35
+ * armed state — i.e. the chord is "spent" on any keypress.
36
+ */
37
+ followUps: Array<{
38
+ match: ChordPredicate;
39
+ handler: ChordHandler;
40
+ }>;
41
+ /** When false, the hook is dormant. Defaults to `true`. */
42
+ isActive?: boolean;
43
+ /** Window after the prefix during which a follow-up is accepted. */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ export function useChordKeyboard(spec: ChordSpec): void {
48
+ const armedRef = useRef<ReturnType<typeof setTimeout> | null>(null);
49
+ const timeoutMs = spec.timeoutMs ?? 1000;
50
+
51
+ // Clear any pending timer if the component using the hook unmounts mid-chord.
52
+ useEffect(() => {
53
+ return () => {
54
+ if (armedRef.current) {
55
+ clearTimeout(armedRef.current);
56
+ armedRef.current = null;
57
+ }
58
+ };
59
+ }, []);
60
+
61
+ useInput(
62
+ (input, key) => {
63
+ const event: ChordKey = { input, key };
64
+
65
+ if (armedRef.current) {
66
+ // We're inside the chord window. Any keypress consumes the chord;
67
+ // dispatch when one of the follow-ups matches.
68
+ clearTimeout(armedRef.current);
69
+ armedRef.current = null;
70
+ for (const fu of spec.followUps) {
71
+ if (fu.match(event)) {
72
+ fu.handler();
73
+ return;
74
+ }
75
+ }
76
+ return;
77
+ }
78
+
79
+ if (spec.prefix(event)) {
80
+ armedRef.current = setTimeout(() => {
81
+ armedRef.current = null;
82
+ }, timeoutMs);
83
+ }
84
+ },
85
+ { isActive: spec.isActive ?? true },
86
+ );
87
+ }