mu-coding 0.5.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 (74) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/config/index.test.ts +26 -0
  8. package/src/config/index.ts +25 -7
  9. package/src/plugin.ts +96 -0
  10. package/src/runtime/codingTools/bash.ts +114 -0
  11. package/src/runtime/codingTools/edit-file.ts +60 -0
  12. package/src/runtime/codingTools/index.ts +39 -0
  13. package/src/runtime/codingTools/read-file.ts +83 -0
  14. package/src/runtime/codingTools/utils.ts +21 -0
  15. package/src/runtime/codingTools/write-file.ts +42 -0
  16. package/src/runtime/createRegistry.test.ts +146 -0
  17. package/src/runtime/createRegistry.ts +128 -23
  18. package/src/runtime/messageBus.test.ts +62 -0
  19. package/src/runtime/messageBus.ts +78 -0
  20. package/src/runtime/pluginLoader.ts +22 -9
  21. package/src/sessions/index.ts +2 -9
  22. package/src/tui/channel/tuiChannel.test.ts +107 -0
  23. package/src/tui/channel/tuiChannel.ts +49 -0
  24. package/src/tui/chat/MessageRendererContext.ts +44 -0
  25. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  26. package/src/tui/chat/useAttachment.ts +1 -1
  27. package/src/tui/chat/useChat.ts +31 -3
  28. package/src/tui/chat/useChatPanel.ts +7 -5
  29. package/src/tui/chat/useChatSession.ts +222 -53
  30. package/src/tui/chat/useModels.ts +2 -1
  31. package/src/tui/chat/usePluginStatus.ts +1 -1
  32. package/src/tui/chat/useSessionPersistence.ts +25 -14
  33. package/src/tui/chat/useStatusSegments.ts +17 -4
  34. package/src/tui/components/chat/ChatPanel.tsx +10 -4
  35. package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
  36. package/src/tui/components/messageView.tsx +4 -2
  37. package/src/tui/components/messages/EditOutput.tsx +6 -4
  38. package/src/tui/components/messages/ToolHeader.tsx +3 -1
  39. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  40. package/src/tui/components/messages/messageItem.tsx +19 -1
  41. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  42. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  43. package/src/tui/components/messages/toolCallBlock.tsx +6 -5
  44. package/src/tui/components/messages/userMessage.tsx +21 -6
  45. package/src/tui/components/primitives/dropdown.tsx +8 -4
  46. package/src/tui/components/primitives/modal.tsx +4 -2
  47. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  48. package/src/tui/components/primitives/toast.tsx +5 -3
  49. package/src/tui/components/statusBar.tsx +8 -1
  50. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  51. package/src/tui/context/ThemeContext.tsx +18 -0
  52. package/src/tui/input/InputBoxView.tsx +135 -26
  53. package/src/tui/input/commands.test.ts +3 -1
  54. package/src/tui/input/commands.ts +6 -1
  55. package/src/tui/input/cursor.test.ts +136 -0
  56. package/src/tui/input/cursor.ts +214 -0
  57. package/src/tui/input/dumpContext.ts +107 -0
  58. package/src/tui/input/sanitize.ts +1 -1
  59. package/src/tui/input/useCommandExecutor.ts +1 -1
  60. package/src/tui/input/useInputBox.ts +134 -15
  61. package/src/tui/input/useInputHandler.ts +316 -126
  62. package/src/tui/input/useMentionPicker.ts +121 -0
  63. package/src/tui/input/usePluginShortcuts.ts +29 -0
  64. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  65. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  66. package/src/tui/plugins/InkUIService.ts +1 -1
  67. package/src/tui/renderApp.tsx +26 -13
  68. package/src/tui/theme/index.ts +1 -0
  69. package/src/tui/theme/merge.test.ts +49 -0
  70. package/src/tui/theme/merge.ts +43 -0
  71. package/src/tui/theme/presets.ts +79 -0
  72. package/src/tui/theme/types.ts +116 -0
  73. package/src/utils/clipboard.ts +1 -1
  74. package/src/tui/chat/useStreamConsumer.ts +0 -118
@@ -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,7 +1,7 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { ToolDisplayHint } from 'mu-agents';
3
- import type { ChatMessage } from 'mu-provider';
2
+ import type { ChatMessage, ToolDisplayHint } from 'mu-core';
4
3
  import { useToolDisplay } from '../../chat/ToolDisplayContext';
4
+ import { useTheme } from '../../context/ThemeContext';
5
5
  import { useSpinner } from '../../hooks/useUI';
6
6
  import { EditOutput } from './EditOutput';
7
7
  import { ReadOutput } from './ReadOutput';
@@ -90,6 +90,7 @@ interface GenericProps {
90
90
  }
91
91
 
92
92
  function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
93
+ const theme = useTheme();
93
94
  let summary = '';
94
95
  const commandField = hint?.fields?.command;
95
96
  if (commandField) {
@@ -104,7 +105,7 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
104
105
  const preview = content.length > 200 ? `${content.slice(0, 200)}…` : content;
105
106
  return (
106
107
  <Box flexDirection="column" flexShrink={0}>
107
- <Text color={error ? 'red' : 'green'} bold={true}>
108
+ <Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
108
109
  {error ? '✗' : '✓'} {name}
109
110
  {summary && (
110
111
  <>
@@ -113,8 +114,8 @@ function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
113
114
  </>
114
115
  )}
115
116
  </Text>
116
- <Box flexDirection="column" backgroundColor="#111111" padding={1} marginTop={1}>
117
- <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>
118
119
  </Box>
119
120
  </Box>
120
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,6 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useMemo, useState } from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
  import { sanitizeTerminalInput } from '../../input/sanitize';
4
5
 
5
6
  interface DropdownItem {
@@ -33,6 +34,7 @@ export function Dropdown({
33
34
  onCancel,
34
35
  isActive = true,
35
36
  }: DropdownProps) {
37
+ const theme = useTheme();
36
38
  const [query, setQuery] = useState('');
37
39
  const [index, setIndex] = useState(0);
38
40
 
@@ -82,7 +84,7 @@ export function Dropdown({
82
84
  if (filtered.length === 0) {
83
85
  return (
84
86
  <Box paddingX={1}>
85
- <Text dimColor={true} italic={true}>
87
+ <Text color={theme.dropdown.empty} italic={true}>
86
88
  No results
87
89
  </Text>
88
90
  </Box>
@@ -90,7 +92,7 @@ export function Dropdown({
90
92
  }
91
93
  return visibleItems.map((item, i) => {
92
94
  const isSel = i === index - visibleStart;
93
- const color = isSel ? 'green' : undefined;
95
+ const color = isSel ? theme.dropdown.selected : undefined;
94
96
  return (
95
97
  <Box key={item.value} paddingX={1}>
96
98
  <Text color={color} bold={isSel}>
@@ -106,9 +108,11 @@ export function Dropdown({
106
108
  return (
107
109
  <Box flexDirection="column">
108
110
  <Box paddingX={1} marginBottom={1}>
109
- <Text dimColor={true}>{placeholder} </Text>
111
+ <Text color={theme.dropdown.placeholder}>{placeholder} </Text>
110
112
  <Text>{query}</Text>
111
- <Text inverse={true}>▎</Text>
113
+ <Text color={theme.dropdown.cursor} inverse={true}>
114
+
115
+ </Text>
112
116
  </Box>
113
117
  {renderResults()}
114
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}
@@ -1,4 +1,5 @@
1
1
  import { Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
  import { Dropdown } from './dropdown';
3
4
  import { Modal } from './modal';
4
5
 
@@ -25,10 +26,11 @@ export function PickerModal({
25
26
  onSelect: (value: string) => void;
26
27
  onCancel?: () => void;
27
28
  }) {
29
+ const theme = useTheme();
28
30
  return (
29
31
  <Modal visible={visible} title={title}>
30
32
  {items.length === 0 && emptyMessage ? (
31
- <Text dimColor={true} italic={true}>
33
+ <Text color={theme.dropdown.empty} italic={true}>
32
34
  {emptyMessage}
33
35
  </Text>
34
36
  ) : (
@@ -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>
@@ -1,4 +1,5 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../context/ThemeContext';
2
3
 
3
4
  export interface StatusBarSegment {
4
5
  text: string;
@@ -7,13 +8,19 @@ export interface StatusBarSegment {
7
8
  }
8
9
 
9
10
  export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
11
+ const theme = useTheme();
10
12
  return (
11
13
  <Box flexShrink={0} paddingX={1} marginY={1}>
12
14
  <Box justifyContent="flex-end" flexGrow={1}>
13
15
  {segments.map((seg, i) => (
14
16
  // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
15
17
  <Box key={i}>
16
- {i > 0 && <Text dimColor={true}> · </Text>}
18
+ {i > 0 && (
19
+ <Text color={theme.status.separator} dimColor={true}>
20
+ {' '}
21
+ ·{' '}
22
+ </Text>
23
+ )}
17
24
  <Text color={seg.color} dimColor={seg.dim}>
18
25
  {seg.text}
19
26
  </Text>
@@ -1,5 +1,6 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useCallback, useEffect, useState } from 'react';
3
+ import { useTheme } from '../../context/ThemeContext';
3
4
  import { sanitizeTerminalInput } from '../../input/sanitize';
4
5
  import type { DialogRequest, InkUIService } from '../../plugins/InkUIService';
5
6
  import { Dropdown } from '../primitives/dropdown';
@@ -16,6 +17,7 @@ function ConfirmDialog({
16
17
  onResolve: (value: unknown) => void;
17
18
  onCancel: () => void;
18
19
  }) {
20
+ const theme = useTheme();
19
21
  const [selected, setSelected] = useState(0);
20
22
 
21
23
  useInput((input, key) => {
@@ -42,15 +44,15 @@ function ConfirmDialog({
42
44
  </Box>
43
45
  )}
44
46
  <Box gap={2}>
45
- <Text color={selected === 0 ? 'green' : undefined} bold={selected === 0}>
47
+ <Text color={selected === 0 ? theme.dialog.confirmYes : undefined} bold={selected === 0}>
46
48
  {selected === 0 ? '▸ ' : ' '}Yes
47
49
  </Text>
48
- <Text color={selected === 1 ? 'red' : undefined} bold={selected === 1}>
50
+ <Text color={selected === 1 ? theme.dialog.confirmNo : undefined} bold={selected === 1}>
49
51
  {selected === 1 ? '▸ ' : ' '}No
50
52
  </Text>
51
53
  </Box>
52
54
  <Box marginTop={1}>
53
- <Text dimColor={true}>y/n · Enter to confirm · Esc to cancel</Text>
55
+ <Text color={theme.dialog.hint}>y/n · Enter to confirm · Esc to cancel</Text>
54
56
  </Box>
55
57
  </Modal>
56
58
  );
@@ -96,6 +98,7 @@ function InputDialog({
96
98
  onResolve: (value: unknown) => void;
97
99
  onCancel: () => void;
98
100
  }) {
101
+ const theme = useTheme();
99
102
  const [value, setValue] = useState('');
100
103
 
101
104
  useInput((input, key) => {
@@ -121,12 +124,14 @@ function InputDialog({
121
124
  <Modal visible={true} title={dialog.title}>
122
125
  <Box flexDirection="column">
123
126
  <Box paddingX={1} marginBottom={1}>
124
- {!value && dialog.placeholder && <Text dimColor={true}>{dialog.placeholder}</Text>}
127
+ {!value && dialog.placeholder && <Text color={theme.dialog.placeholder}>{dialog.placeholder}</Text>}
125
128
  {value && <Text>{value}</Text>}
126
- <Text inverse={true}>▎</Text>
129
+ <Text color={theme.dialog.cursor} inverse={true}>
130
+
131
+ </Text>
127
132
  </Box>
128
133
  <Box>
129
- <Text dimColor={true}>Enter to submit · Esc to cancel</Text>
134
+ <Text color={theme.dialog.hint}>Enter to submit · Esc to cancel</Text>
130
135
  </Box>
131
136
  </Box>
132
137
  </Modal>
@@ -0,0 +1,18 @@
1
+ import { createContext, type ReactNode, useContext } from 'react';
2
+ import { DEFAULT_THEME } from '../theme/presets';
3
+ import type { Theme } from '../theme/types';
4
+
5
+ /**
6
+ * Theme is read in many small components, so a dedicated context keeps
7
+ * `ChatContext` focused on session/runtime state and avoids re-renders
8
+ * cascading through unrelated subtrees when chat state changes.
9
+ */
10
+ const ThemeContext = createContext<Theme>(DEFAULT_THEME);
11
+
12
+ export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
13
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
14
+ }
15
+
16
+ export function useTheme(): Theme {
17
+ return useContext(ThemeContext);
18
+ }
@@ -1,8 +1,18 @@
1
1
  import { Box, Text } from 'ink';
2
+ import type { MentionCompletion } from 'mu-core';
3
+ import { useTheme } from '../context/ThemeContext';
4
+ import type { Theme } from '../theme/types';
2
5
  import type { SlashCommand } from './commands';
3
6
 
7
+ interface MentionPickerView {
8
+ completions: MentionCompletion[];
9
+ selectedIndex: number;
10
+ partial: string;
11
+ }
12
+
4
13
  export interface InputBoxViewProps {
5
14
  value: string;
15
+ cursor: number;
6
16
  commands: SlashCommand[];
7
17
  cmdIndex: number;
8
18
  isCommandMode: boolean;
@@ -11,9 +21,18 @@ export interface InputBoxViewProps {
11
21
  model: string;
12
22
  attachmentName: string | null;
13
23
  attachmentError: string | null;
24
+ mentions: MentionPickerView | null;
14
25
  }
15
26
 
16
- function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
27
+ function CommandHints({
28
+ commands,
29
+ selectedIndex,
30
+ theme,
31
+ }: {
32
+ commands: SlashCommand[];
33
+ selectedIndex: number;
34
+ theme: Theme;
35
+ }) {
17
36
  if (!commands.length) {
18
37
  return null;
19
38
  }
@@ -21,7 +40,7 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
21
40
  <Box flexDirection="column" marginBottom={1}>
22
41
  {commands.map((cmd, i) => (
23
42
  <Box key={cmd.name} paddingX={1}>
24
- <Text color={i === selectedIndex ? 'green' : undefined} bold={i === selectedIndex}>
43
+ <Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
25
44
  {i === selectedIndex ? '▸ ' : ' '}
26
45
  {cmd.name}
27
46
  </Text>
@@ -32,88 +51,176 @@ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; s
32
51
  );
33
52
  }
34
53
 
54
+ function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
55
+ if (!mentions.completions.length) {
56
+ return null;
57
+ }
58
+ return (
59
+ <Box flexDirection="column" marginBottom={1}>
60
+ {mentions.completions.map((c, i) => (
61
+ <Box key={c.value} paddingX={1}>
62
+ <Text
63
+ color={i === mentions.selectedIndex ? theme.input.commandHighlight : undefined}
64
+ bold={i === mentions.selectedIndex}
65
+ >
66
+ {i === mentions.selectedIndex ? '▸ @' : ' @'}
67
+ {c.label ?? c.value}
68
+ </Text>
69
+ {c.description && <Text dimColor={true}> {c.description}</Text>}
70
+ </Box>
71
+ ))}
72
+ </Box>
73
+ );
74
+ }
75
+
35
76
  function InputFooter({
36
77
  model,
37
78
  attachmentName,
38
79
  attachmentError,
39
80
  hasContent,
40
81
  isCommandMode,
82
+ hasMentions,
83
+ theme,
41
84
  }: {
42
85
  model: string;
43
86
  attachmentName: string | null;
44
87
  attachmentError: string | null;
45
88
  hasContent: boolean;
46
89
  isCommandMode: boolean;
90
+ hasMentions: boolean;
91
+ theme: Theme;
47
92
  }) {
48
- const hint = hasContent
49
- ? isCommandMode
50
- ? '↑↓ select · Enter execute'
51
- : 'Enter to send · Shift+Enter for newline'
52
- : 'Type / for commands';
93
+ const hint = hasMentions
94
+ ? '↑↓ select · Tab/Enter accept'
95
+ : hasContent
96
+ ? isCommandMode
97
+ ? '↑↓ select · Enter execute'
98
+ : 'Enter to send · Shift+Enter for newline · ←→ move'
99
+ : 'Type / for commands · @ for mentions';
53
100
 
54
101
  return (
55
102
  <Box justifyContent="space-between">
56
103
  <Box gap={1}>
57
104
  {model && (
58
- <Text color="white" bold={true}>
105
+ <Text color={theme.input.modelLabel} bold={true}>
59
106
  {model}
60
107
  </Text>
61
108
  )}
62
- {attachmentName && <Text color="cyan">📷 {attachmentName}</Text>}
63
- {attachmentError && <Text color="red">{attachmentError}</Text>}
109
+ {attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
110
+ {attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
64
111
  </Box>
65
- <Text dimColor={true}>{hint}</Text>
112
+ <Text color={theme.input.footerHint}>{hint}</Text>
66
113
  </Box>
67
114
  );
68
115
  }
69
116
 
70
- function InputDisplay({
71
- value,
72
- isCommandMode,
73
- streaming,
74
- isActive,
75
- }: {
117
+ interface RowProps {
118
+ line: string;
119
+ cursorCol: number | null;
120
+ isCommandLine: boolean;
121
+ theme: Theme;
122
+ }
123
+
124
+ /**
125
+ * Render one buffer row, splicing the cursor glyph at `cursorCol` if the
126
+ * cursor lives on this row. Splitting around the cursor (rather than rendering
127
+ * the whole line and overlaying) keeps the layout flowing inside Ink's text
128
+ * wrapping engine.
129
+ */
130
+ function InputRow({ line, cursorCol, isCommandLine, theme }: RowProps) {
131
+ const colorize = (text: string) => (isCommandLine ? <Text color={theme.input.commandHighlight}>{text}</Text> : text);
132
+
133
+ if (cursorCol === null) {
134
+ return <Text wrap="wrap">{colorize(line)}</Text>;
135
+ }
136
+
137
+ const before = line.slice(0, cursorCol);
138
+ const after = line.slice(cursorCol);
139
+ return (
140
+ <Text wrap="wrap">
141
+ {colorize(before)}
142
+ <Text color={theme.input.cursor} inverse={true}>
143
+
144
+ </Text>
145
+ {colorize(after)}
146
+ </Text>
147
+ );
148
+ }
149
+
150
+ interface DisplayProps {
76
151
  value: string;
152
+ cursor: number;
77
153
  isCommandMode: boolean;
78
154
  streaming: boolean;
79
155
  isActive: boolean;
80
- }) {
156
+ theme: Theme;
157
+ }
158
+
159
+ function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
81
160
  const showCursor = !streaming && isActive;
82
161
  if (!value.length) {
83
- return <Text>{showCursor && <Text inverse={true}>▎</Text>}</Text>;
162
+ return (
163
+ <Text>
164
+ {showCursor && (
165
+ <Text color={theme.input.cursor} inverse={true}>
166
+
167
+ </Text>
168
+ )}
169
+ </Text>
170
+ );
84
171
  }
85
172
  const lines = value.split('\n');
173
+ // Locate cursor row/col by walking newline offsets.
174
+ let row = 0;
175
+ let consumed = 0;
176
+ for (let i = 0; i < lines.length; i++) {
177
+ const lineEnd = consumed + lines[i].length;
178
+ if (cursor <= lineEnd) {
179
+ row = i;
180
+ break;
181
+ }
182
+ consumed = lineEnd + 1; // +1 for the newline
183
+ row = i + 1;
184
+ }
185
+ const col = cursor - consumed;
86
186
  return (
87
187
  <>
88
188
  {lines.map((line, i) => (
89
- // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
90
- <Text key={`${i}-${line}`} wrap="wrap">
91
- {i === 0 && isCommandMode ? <Text color="green">{line}</Text> : line}
92
- {i === lines.length - 1 && showCursor && <Text inverse={true}>▎</Text>}
93
- </Text>
189
+ <InputRow
190
+ // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
191
+ key={`${i}-${line}`}
192
+ line={line}
193
+ cursorCol={showCursor && i === row ? col : null}
194
+ isCommandLine={i === 0 && isCommandMode}
195
+ theme={theme}
196
+ />
94
197
  ))}
95
198
  </>
96
199
  );
97
200
  }
98
201
 
99
202
  export function InputBoxView(props: InputBoxViewProps) {
203
+ const theme = useTheme();
100
204
  return (
101
205
  <Box
102
206
  flexDirection="column"
103
207
  flexShrink={0}
104
- backgroundColor="#222222"
208
+ backgroundColor={theme.input.background}
105
209
  paddingX={1}
106
210
  paddingY={1}
107
211
  marginX={1}
108
212
  marginTop={1}
109
213
  >
110
- {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} />}
214
+ {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
215
+ {props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
111
216
  <Box flexDirection="column" minHeight={2}>
112
217
  <InputDisplay
113
218
  value={props.value}
219
+ cursor={props.cursor}
114
220
  isCommandMode={props.isCommandMode}
115
221
  streaming={props.streaming}
116
222
  isActive={props.isActive}
223
+ theme={theme}
117
224
  />
118
225
  </Box>
119
226
  <InputFooter
@@ -122,6 +229,8 @@ export function InputBoxView(props: InputBoxViewProps) {
122
229
  attachmentError={props.attachmentError}
123
230
  hasContent={props.value.length > 0}
124
231
  isCommandMode={props.isCommandMode}
232
+ hasMentions={Boolean(props.mentions)}
233
+ theme={theme}
125
234
  />
126
235
  </Box>
127
236
  );
@@ -23,7 +23,8 @@ describe('BUILTIN_COMMANDS', () => {
23
23
  const onTogglePicker = mock(() => undefined);
24
24
  const onToggleSessionPicker = mock(() => undefined);
25
25
  const onNew = mock(() => undefined);
26
- const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew };
26
+ const onShowContext = mock(() => undefined);
27
+ const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
27
28
 
28
29
  for (const cmd of BUILTIN_COMMANDS) {
29
30
  cmd.invoke?.(actions);
@@ -32,6 +33,7 @@ describe('BUILTIN_COMMANDS', () => {
32
33
  expect(onTogglePicker).toHaveBeenCalledTimes(1);
33
34
  expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
34
35
  expect(onNew).toHaveBeenCalledTimes(1);
36
+ expect(onShowContext).toHaveBeenCalledTimes(1);
35
37
  });
36
38
  });
37
39
 
@@ -1,4 +1,4 @@
1
- import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
2
2
  import type { InputActions } from './useInputHandler';
3
3
 
4
4
  /**
@@ -20,6 +20,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
20
20
  { name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
21
21
  { name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
22
22
  { name: '/new', description: 'New conversation', invoke: (a) => a.onNew?.() },
23
+ {
24
+ name: '/context',
25
+ description: 'Show the LLM context (system prompt, messages, tools) as plain text',
26
+ invoke: (a) => a.onShowContext?.(),
27
+ },
23
28
  ];
24
29
 
25
30
  export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {