mu-coding 0.4.0 → 0.8.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 (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage } from 'mu-core';
3
3
  import React from 'react';
4
4
  import { ReasoningBlock } from './reasoningBlock';
5
5
  import { ToolCallBlock } from './toolCallBlock';
@@ -8,8 +8,18 @@ export const AssistantMessage: React.FC<{
8
8
  msg: ChatMessage;
9
9
  toolMessages?: ChatMessage[];
10
10
  }> = React.memo(function AssistantMessage({ msg, toolMessages }) {
11
+ const badge = msg.display?.badge;
12
+ const prefix = msg.display?.prefix;
13
+ const color = msg.display?.color;
11
14
  return (
12
15
  <Box flexDirection="column" flexShrink={0} marginBottom={1}>
16
+ {badge && (
17
+ <Box marginBottom={1}>
18
+ <Text color={color} bold={true}>
19
+ [{badge}]
20
+ </Text>
21
+ </Box>
22
+ )}
13
23
  {msg.reasoning && <ReasoningBlock reasoning={msg.reasoning} />}
14
24
  {msg.toolCalls?.length ? (
15
25
  <Box flexDirection="column" marginBottom={1}>
@@ -18,7 +28,12 @@ export const AssistantMessage: React.FC<{
18
28
  ))}
19
29
  </Box>
20
30
  ) : null}
21
- {msg.content && <Text wrap="wrap">{msg.content}</Text>}
31
+ {msg.content && (
32
+ <Text wrap="wrap" color={color}>
33
+ {prefix && <Text color={color}>{prefix}</Text>}
34
+ {msg.content}
35
+ </Text>
36
+ )}
22
37
  </Box>
23
38
  );
24
39
  });
@@ -1,30 +1,37 @@
1
- import type { ChatMessage } from 'mu-provider';
1
+ import type { ChatMessage } from 'mu-core';
2
2
  import React from 'react';
3
+ import { useMessageRenderer } from '../../chat/MessageRendererContext';
3
4
  import { AssistantMessage } from './assistantMessage';
4
5
  import { UserMessage } from './userMessage';
5
6
 
6
7
  export const MessageItem: React.FC<{
7
8
  msg: ChatMessage;
8
- messages: ChatMessage[];
9
- index: number;
10
- }> = React.memo(function MessageItem({ msg, messages, index }) {
11
- // Tool result messages are rendered inline within ToolCallBlock
9
+ toolMessages?: ChatMessage[];
10
+ }> = React.memo(function MessageItem({ msg, toolMessages }) {
11
+ const customRenderer = useMessageRenderer(msg.customType);
12
+
13
+ // Plugins may flag a message as `hidden` to keep it in the LLM transcript
14
+ // while suppressing on-screen rendering (e.g. system reminders carried with
15
+ // the user's next turn).
16
+ if (msg.display?.hidden) {
17
+ return null;
18
+ }
19
+
20
+ // Custom-typed messages always defer to a registered renderer when one is
21
+ // available; otherwise fall through to the role-default rendering so a
22
+ // plugin can ship messages whose renderer isn't loaded yet without losing
23
+ // their content.
24
+ if (customRenderer) {
25
+ return <>{customRenderer(msg)}</>;
26
+ }
27
+
28
+ // Tool result messages are rendered inline within ToolCallBlock via the
29
+ // pre-built index passed from MessageView; suppress them at the top level.
12
30
  if (msg.role === 'tool') {
13
31
  return null;
14
32
  }
15
33
 
16
- // Check if this assistant message has tool calls
17
34
  if (msg.role === 'assistant' && msg.toolCalls?.length) {
18
- // Collect following tool messages
19
- const toolMessages: ChatMessage[] = [];
20
- for (let i = index + 1; i < messages.length; i++) {
21
- if (messages[i].role === 'tool') {
22
- toolMessages.push(messages[i]);
23
- } else {
24
- break;
25
- }
26
- }
27
-
28
35
  return <AssistantMessage msg={msg} toolMessages={toolMessages} />;
29
36
  }
30
37
 
@@ -1,12 +1,14 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
 
3
4
  export function ReasoningBlock({ reasoning }: { reasoning: string }) {
5
+ const theme = useTheme();
4
6
  return (
5
7
  <Box flexDirection="column" marginTop={0} marginBottom={1}>
6
- <Text color="yellow" italic={true}>
8
+ <Text color={theme.reasoning.title} italic={true}>
7
9
  thinking
8
10
  </Text>
9
- <Text dimColor={true} italic={true} wrap="wrap">
11
+ <Text color={theme.reasoning.body} italic={true} wrap="wrap">
10
12
  {reasoning}
11
13
  </Text>
12
14
  </Box>
@@ -1,13 +1,17 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
  import { ReasoningBlock } from './reasoningBlock';
3
4
 
4
5
  export function StreamingOutput({ currentText, currentReasoning }: { currentText: string; currentReasoning: string }) {
6
+ const theme = useTheme();
5
7
  return (
6
8
  <Box flexDirection="column" flexShrink={0} marginBottom={1}>
7
9
  {currentReasoning && <ReasoningBlock reasoning={currentReasoning} />}
8
10
  <Text wrap="wrap">
9
11
  {currentText}
10
- <Text inverse={true}>▎</Text>
12
+ <Text color={theme.input.cursor} inverse={true}>
13
+
14
+ </Text>
11
15
  </Text>
12
16
  </Box>
13
17
  );
@@ -1,27 +1,32 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage, ToolDisplayHint } from 'mu-core';
3
+ import { useToolDisplay } from '../../chat/ToolDisplayContext';
4
+ import { useTheme } from '../../context/ThemeContext';
3
5
  import { useSpinner } from '../../hooks/useUI';
4
6
  import { EditOutput } from './EditOutput';
5
7
  import { ReadOutput } from './ReadOutput';
6
8
  import { WriteOutput } from './WriteOutput';
7
9
 
8
- const TOOL_VERBS: Record<string, string> = {
9
- bash: 'running',
10
- read_file: 'reading',
11
- write_file: 'writing',
12
- edit_file: 'editing',
13
- };
10
+ /**
11
+ * Render a tool call. Display behaviour is driven by the optional
12
+ * `ToolDisplayHint` the plugin attached to its tool — `kind` selects the
13
+ * dedicated renderer (file-read / file-write / diff / shell), and `verb`
14
+ * shows in the spinner line. Tools without a hint fall back to a generic
15
+ * preview block, so plugin-registered tools "just work" without UI changes.
16
+ */
14
17
 
15
- function getToolArgSummary(name: string, args: string): string {
16
- if (name === 'bash') {
17
- try {
18
- const parsed = JSON.parse(args);
19
- return parsed.command ?? args;
20
- } catch {
21
- return args;
22
- }
18
+ function getArgSummary(args: string, hint: ToolDisplayHint | undefined): string {
19
+ if (!hint?.fields) return args;
20
+ // For shell-like tools the most useful preview is the command itself;
21
+ // generic tools show the raw JSON.
22
+ const commandField = hint.fields.command;
23
+ if (!commandField) return args;
24
+ try {
25
+ const parsed = JSON.parse(args);
26
+ return parsed[commandField] ?? args;
27
+ } catch {
28
+ return args;
23
29
  }
24
- return args;
25
30
  }
26
31
 
27
32
  export function ToolCallBlock({
@@ -33,13 +38,13 @@ export function ToolCallBlock({
33
38
  }) {
34
39
  const name = toolCall.function.name;
35
40
  const args = toolCall.function.arguments;
41
+ const hint = useToolDisplay(name);
36
42
 
37
- // Find the matching tool result message
38
43
  const result = toolMsg?.toolResult;
39
44
  const hasResult = result !== undefined;
40
45
  const spinner = useSpinner(!hasResult);
41
- const verb = TOOL_VERBS[name] ?? 'executing';
42
- const argSummary = getToolArgSummary(name, args);
46
+ const verb = hint?.verb ?? 'executing';
47
+ const argSummary = getArgSummary(args, hint);
43
48
 
44
49
  return (
45
50
  <Box flexDirection="column" flexShrink={0}>
@@ -51,29 +56,47 @@ export function ToolCallBlock({
51
56
  </Text>
52
57
  </Box>
53
58
  ) : (
54
- renderToolOutput(name, args, result.content, result.error ?? false, result.expanded)
59
+ renderToolOutput(name, args, result.content, result.error ?? false, hint)
55
60
  )}
56
61
  </Box>
57
62
  );
58
63
  }
59
64
 
60
- function renderToolOutput(name: string, args: string, content: string, error: boolean, expanded?: boolean) {
61
- if (name === 'read_file') {
62
- return <ReadOutput args={args} error={error} />;
63
- }
64
- if (name === 'write_file') {
65
- return <WriteOutput args={args} content={content} error={error} expanded={expanded ?? false} />;
66
- }
67
- if (name === 'edit_file') {
68
- return <EditOutput args={args} content={content} error={error} />;
65
+ function renderToolOutput(
66
+ name: string,
67
+ args: string,
68
+ content: string,
69
+ error: boolean,
70
+ hint: ToolDisplayHint | undefined,
71
+ ) {
72
+ switch (hint?.kind) {
73
+ case 'file-read':
74
+ return <ReadOutput args={args} error={error} />;
75
+ case 'file-write':
76
+ return <WriteOutput args={args} content={content} error={error} />;
77
+ case 'diff':
78
+ return <EditOutput args={args} content={content} error={error} hint={hint} />;
79
+ default:
80
+ return <GenericToolOutput name={name} args={args} content={content} error={error} hint={hint} />;
69
81
  }
82
+ }
83
+
84
+ interface GenericProps {
85
+ name: string;
86
+ args: string;
87
+ content: string;
88
+ error: boolean;
89
+ hint: ToolDisplayHint | undefined;
90
+ }
70
91
 
71
- // Fallback for bash and unknown tools
72
- let command = '';
73
- if (name === 'bash') {
92
+ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
93
+ const theme = useTheme();
94
+ let summary = '';
95
+ const commandField = hint?.fields?.command;
96
+ if (commandField) {
74
97
  try {
75
98
  const parsed = JSON.parse(args);
76
- command = parsed.command ?? '';
99
+ summary = parsed[commandField] ?? '';
77
100
  } catch {
78
101
  // ignore
79
102
  }
@@ -82,17 +105,17 @@ function renderToolOutput(name: string, args: string, content: string, error: bo
82
105
  const preview = content.length > 200 ? `${content.slice(0, 200)}…` : content;
83
106
  return (
84
107
  <Box flexDirection="column" flexShrink={0}>
85
- <Text color={error ? 'red' : 'green'} bold={true}>
108
+ <Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
86
109
  {error ? '✗' : '✓'} {name}
87
- {command && (
110
+ {summary && (
88
111
  <>
89
112
  {' '}
90
- <Text dimColor={true}>{command}</Text>
113
+ <Text dimColor={true}>{summary}</Text>
91
114
  </>
92
115
  )}
93
116
  </Text>
94
- <Box flexDirection="column" backgroundColor="#111111" padding={1} marginTop={1}>
95
- <Text color="white">{preview}</Text>
117
+ <Box flexDirection="column" backgroundColor={theme.tool.previewBackground} padding={1} marginTop={1}>
118
+ <Text color={theme.tool.previewText}>{preview}</Text>
96
119
  </Box>
97
120
  </Box>
98
121
  );
@@ -1,29 +1,44 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage } from 'mu-core';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
 
4
5
  export function UserMessage({ msg }: { msg: ChatMessage }) {
6
+ const theme = useTheme();
7
+ const borderColor = msg.display?.color ?? theme.user.border;
8
+ const badge = msg.display?.badge;
9
+ const prefix = msg.display?.prefix;
5
10
  return (
6
11
  <Box
7
12
  flexDirection="column"
8
13
  flexShrink={0}
9
14
  marginY={1}
10
- backgroundColor="#1a1a1a"
15
+ backgroundColor={theme.user.background}
11
16
  paddingX={1}
12
17
  paddingY={1}
13
18
  borderLeft={true}
14
19
  borderTop={false}
15
20
  borderBottom={false}
16
21
  borderRight={false}
17
- borderColor="yellow"
22
+ borderColor={borderColor}
18
23
  borderStyle="single"
19
24
  >
25
+ {badge && (
26
+ <Box marginBottom={1}>
27
+ <Text color={msg.display?.color} bold={true}>
28
+ [{badge}]
29
+ </Text>
30
+ </Box>
31
+ )}
20
32
  {msg.images && msg.images.length > 0 && (
21
33
  <Box>
22
- <Text color="cyan">📷 </Text>
23
- <Text color="cyan">{msg.images.map((img) => img.name).join(', ')}</Text>
34
+ <Text color={theme.user.attachment}>📷 </Text>
35
+ <Text color={theme.user.attachment}>{msg.images.map((img) => img.name).join(', ')}</Text>
24
36
  </Box>
25
37
  )}
26
- <Text wrap="wrap">{msg.content}</Text>
38
+ <Text wrap="wrap">
39
+ {prefix && <Text color={msg.display?.color}>{prefix}</Text>}
40
+ {msg.content}
41
+ </Text>
27
42
  </Box>
28
43
  );
29
44
  }
@@ -1,5 +1,7 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useMemo, useState } from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
4
+ import { sanitizeTerminalInput } from '../../input/sanitize';
3
5
 
4
6
  interface DropdownItem {
5
7
  label: string;
@@ -32,6 +34,7 @@ export function Dropdown({
32
34
  onCancel,
33
35
  isActive = true,
34
36
  }: DropdownProps) {
37
+ const theme = useTheme();
35
38
  const [query, setQuery] = useState('');
36
39
  const [index, setIndex] = useState(0);
37
40
 
@@ -42,13 +45,37 @@ export function Dropdown({
42
45
 
43
46
  useInput(
44
47
  (input, key) => {
45
- if (!isActive || key.tab) return;
46
- if (key.escape) onCancel?.();
47
- else if (key.return && filtered[index]) onSelect(filtered[index]);
48
- else if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
49
- else if (key.downArrow) setIndex((i) => Math.min(filtered.length - 1, i + 1));
50
- else if (key.backspace) setQuery((q) => q.slice(0, -1));
51
- else if (input?.length === 1) setQuery((q) => q + input);
48
+ if (!isActive) return;
49
+ // Tab is reserved for the input box's "insert two spaces" binding when
50
+ // dropdowns are not focused; inside a focused dropdown we ignore it
51
+ // rather than risk inserting whitespace into the query.
52
+ if (key.tab) return;
53
+ if (key.escape) {
54
+ onCancel?.();
55
+ return;
56
+ }
57
+ if (key.return && filtered[index]) {
58
+ onSelect(filtered[index]);
59
+ return;
60
+ }
61
+ if (key.upArrow) {
62
+ setIndex((i) => Math.max(0, i - 1));
63
+ return;
64
+ }
65
+ if (key.downArrow) {
66
+ setIndex((i) => Math.min(filtered.length - 1, i + 1));
67
+ return;
68
+ }
69
+ if (key.backspace) {
70
+ setQuery((q) => q.slice(0, -1));
71
+ return;
72
+ }
73
+ // Accept multi-char input (paste) into the filter; strip control bytes
74
+ // and any SGR mouse sequences that may leak through. Single-line: drop \t/\n.
75
+ if (input) {
76
+ const clean = sanitizeTerminalInput(input).replace(/[\t\n]/g, '');
77
+ if (clean) setQuery((q) => q + clean);
78
+ }
52
79
  },
53
80
  { isActive },
54
81
  );
@@ -57,7 +84,7 @@ export function Dropdown({
57
84
  if (filtered.length === 0) {
58
85
  return (
59
86
  <Box paddingX={1}>
60
- <Text dimColor={true} italic={true}>
87
+ <Text color={theme.dropdown.empty} italic={true}>
61
88
  No results
62
89
  </Text>
63
90
  </Box>
@@ -65,7 +92,7 @@ export function Dropdown({
65
92
  }
66
93
  return visibleItems.map((item, i) => {
67
94
  const isSel = i === index - visibleStart;
68
- const color = isSel ? 'green' : undefined;
95
+ const color = isSel ? theme.dropdown.selected : undefined;
69
96
  return (
70
97
  <Box key={item.value} paddingX={1}>
71
98
  <Text color={color} bold={isSel}>
@@ -81,9 +108,11 @@ export function Dropdown({
81
108
  return (
82
109
  <Box flexDirection="column">
83
110
  <Box paddingX={1} marginBottom={1}>
84
- <Text dimColor={true}>{placeholder} </Text>
111
+ <Text color={theme.dropdown.placeholder}>{placeholder} </Text>
85
112
  <Text>{query}</Text>
86
- <Text inverse={true}>▎</Text>
113
+ <Text color={theme.dropdown.cursor} inverse={true}>
114
+
115
+ </Text>
87
116
  </Box>
88
117
  {renderResults()}
89
118
  {filtered.length > maxVisible && (
@@ -1,5 +1,6 @@
1
1
  import { Box, Text, useStdout } from 'ink';
2
2
  import type { ReactNode } from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
 
4
5
  interface ModalProps {
5
6
  visible: boolean;
@@ -9,6 +10,7 @@ interface ModalProps {
9
10
  }
10
11
 
11
12
  export function Modal({ visible, title, width: requestedWidth, children }: ModalProps) {
13
+ const theme = useTheme();
12
14
  const { stdout } = useStdout();
13
15
  const columns = stdout.columns;
14
16
  const rows = stdout.rows;
@@ -30,12 +32,12 @@ export function Modal({ visible, title, width: requestedWidth, children }: Modal
30
32
  top={0}
31
33
  left={0}
32
34
  >
33
- <Box flexDirection="column" width={modalWidth} backgroundColor="#1a1a1a" paddingX={2} paddingY={1}>
35
+ <Box flexDirection="column" width={modalWidth} backgroundColor={theme.modal.background} paddingX={2} paddingY={1}>
34
36
  {title && (
35
37
  <Box marginBottom={1}>
36
38
  <Text bold={true}>{title}</Text>
37
39
  <Box flexGrow={1} />
38
- <Text dimColor={true}>Esc to close</Text>
40
+ <Text color={theme.modal.hint}>Esc to close</Text>
39
41
  </Box>
40
42
  )}
41
43
  {children}
@@ -0,0 +1,47 @@
1
+ import { Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
3
+ import { Dropdown } from './dropdown';
4
+ import { Modal } from './modal';
5
+
6
+ interface PickerItem {
7
+ label: string;
8
+ value: string;
9
+ description?: string;
10
+ }
11
+
12
+ export function PickerModal({
13
+ visible,
14
+ title,
15
+ items,
16
+ placeholder,
17
+ emptyMessage,
18
+ onSelect,
19
+ onCancel,
20
+ }: {
21
+ visible: boolean;
22
+ title: string;
23
+ items: PickerItem[];
24
+ placeholder: string;
25
+ emptyMessage?: string;
26
+ onSelect: (value: string) => void;
27
+ onCancel?: () => void;
28
+ }) {
29
+ const theme = useTheme();
30
+ return (
31
+ <Modal visible={visible} title={title}>
32
+ {items.length === 0 && emptyMessage ? (
33
+ <Text color={theme.dropdown.empty} italic={true}>
34
+ {emptyMessage}
35
+ </Text>
36
+ ) : (
37
+ <Dropdown
38
+ items={items}
39
+ placeholder={placeholder}
40
+ isActive={visible}
41
+ onSelect={(item) => onSelect(item.value)}
42
+ onCancel={onCancel}
43
+ />
44
+ )}
45
+ </Modal>
46
+ );
47
+ }
@@ -0,0 +1,27 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ export function Scrollbar({
4
+ viewHeight,
5
+ contentHeight,
6
+ scrollOffset,
7
+ }: {
8
+ viewHeight: number;
9
+ contentHeight: number;
10
+ scrollOffset: number;
11
+ }) {
12
+ if (contentHeight <= viewHeight || viewHeight < 1) {
13
+ return null;
14
+ }
15
+ const maxScroll = contentHeight - viewHeight;
16
+ const ratio = scrollOffset / maxScroll;
17
+ const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
18
+ const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
19
+
20
+ const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
21
+
22
+ return (
23
+ <Box flexDirection="column" flexShrink={0} width={1}>
24
+ <Text>{track.join('')}</Text>
25
+ </Box>
26
+ );
27
+ }
@@ -1,5 +1,6 @@
1
1
  import { Box, Text, useInput, useStdout } from 'ink';
2
2
  import { useCallback, useState } from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
 
4
5
  export interface Toast {
5
6
  id: number;
@@ -29,6 +30,7 @@ export function useToast() {
29
30
  }
30
31
 
31
32
  export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
33
+ const theme = useTheme();
32
34
  const { stdout } = useStdout();
33
35
  const columns = stdout.columns;
34
36
 
@@ -48,14 +50,14 @@ export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismi
48
50
  <Box position="absolute" top={0} left={0} width={columns} justifyContent="flex-end" paddingX={2} paddingY={1}>
49
51
  <Box flexDirection="column" gap={1}>
50
52
  {toasts.map((t) => (
51
- <Box key={t.id} backgroundColor="#1a1a1a" paddingX={2} paddingY={0} width={maxWidth}>
53
+ <Box key={t.id} backgroundColor={theme.toast.background} paddingX={2} paddingY={0} width={maxWidth}>
52
54
  <Box flexGrow={1} flexShrink={1}>
53
- <Text color={t.color ?? 'green'} wrap="wrap">
55
+ <Text color={t.color ?? theme.toast.defaultColor} wrap="wrap">
54
56
  {t.message}
55
57
  </Text>
56
58
  </Box>
57
59
  <Box marginLeft={1} flexShrink={0}>
58
- <Text color="gray" dimColor={true}>
60
+ <Text color={theme.toast.closeHint} dimColor={true}>
59
61
  [esc]✕
60
62
  </Text>
61
63
  </Box>
@@ -0,0 +1,32 @@
1
+ import { Box, Text } from 'ink';
2
+ import { useTheme } from '../context/ThemeContext';
3
+
4
+ export interface StatusBarSegment {
5
+ text: string;
6
+ color?: string;
7
+ dim?: boolean;
8
+ }
9
+
10
+ export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
11
+ const theme = useTheme();
12
+ return (
13
+ <Box flexShrink={0} paddingX={1} marginY={1}>
14
+ <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
+ ))}
29
+ </Box>
30
+ </Box>
31
+ );
32
+ }