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,192 +0,0 @@
1
- import type { DOMElement } from 'ink';
2
- import { Box, Text } from 'ink';
3
- import type { StatusSegment } from 'mu-agents';
4
- import type { ChatMessage } from 'mu-provider';
5
- import type React from 'react';
6
- import { useSpinner } from '../hooks/useUI';
7
- import type { StreamState } from '../useChatSession';
8
- import { MessageItem } from './messages/messageItem';
9
- import { StreamingOutput } from './messages/streamingOutput';
10
- import { Dropdown } from './ui/dropdown';
11
- import { Modal } from './ui/modal';
12
-
13
- function Scrollbar({
14
- viewHeight,
15
- contentHeight,
16
- scrollOffset,
17
- }: {
18
- viewHeight: number;
19
- contentHeight: number;
20
- scrollOffset: number;
21
- }) {
22
- if (contentHeight <= viewHeight || viewHeight < 1) {
23
- return null;
24
- }
25
- const maxScroll = contentHeight - viewHeight;
26
- const ratio = scrollOffset / maxScroll;
27
- const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
28
- const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
29
-
30
- const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
31
-
32
- return (
33
- <Box flexDirection="column" flexShrink={0} width={1}>
34
- <Text>{track.join('')}</Text>
35
- </Box>
36
- );
37
- }
38
-
39
- export function StatusBar({
40
- streaming,
41
- abortWarning,
42
- quitWarning,
43
- error,
44
- modelError,
45
- totalTokens,
46
- tokensPerSecond,
47
- pluginStatus,
48
- }: {
49
- streaming: boolean;
50
- abortWarning: boolean;
51
- quitWarning: boolean;
52
- error: string | null;
53
- modelError: string | null;
54
- totalTokens: number;
55
- tokensPerSecond: number;
56
- pluginStatus?: StatusSegment[];
57
- }) {
58
- const spinner = useSpinner(streaming);
59
- const segments: Array<{ text: string; color?: string; dim?: boolean }> = [];
60
- if (streaming) {
61
- segments.push({ text: `${spinner} generating`, color: 'yellow' });
62
- }
63
- if (tokensPerSecond > 0) {
64
- segments.push({ text: `${tokensPerSecond} tok/s`, dim: true });
65
- }
66
- if (abortWarning) {
67
- segments.push({ text: 'Esc again to stop', color: 'yellow' });
68
- } else if (quitWarning) {
69
- segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
70
- } else if (streaming) {
71
- segments.push({ text: 'Esc to stop', dim: true });
72
- }
73
- if (error) {
74
- segments.push({ text: '⚠ error', color: 'red' });
75
- }
76
- if (modelError) {
77
- segments.push({ text: `⚠ ${modelError}`, color: 'red' });
78
- }
79
-
80
- if (totalTokens > 0) {
81
- segments.push({ text: `${formatTokens(totalTokens)} tokens`, dim: true });
82
- }
83
- if (pluginStatus) {
84
- segments.push(...pluginStatus);
85
- }
86
-
87
- return (
88
- <Box flexShrink={0} paddingX={1} marginY={1}>
89
- <Box justifyContent="flex-end" flexGrow={1}>
90
- {segments.map((seg, i) => (
91
- // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
92
- <Box key={i}>
93
- {i > 0 && <Text dimColor={true}> · </Text>}
94
- <Text color={seg.color} dimColor={seg.dim}>
95
- {seg.text}
96
- </Text>
97
- </Box>
98
- ))}
99
- </Box>
100
- </Box>
101
- );
102
- }
103
-
104
- function formatTokens(tokens: number): string {
105
- if (tokens >= 1_000_000) {
106
- return `${(tokens / 1_000_000).toFixed(1)}M`;
107
- }
108
- if (tokens >= 1_000) {
109
- return `${(tokens / 1_000).toFixed(1)}k`;
110
- }
111
- return String(tokens);
112
- }
113
-
114
- interface PickerItem {
115
- label: string;
116
- value: string;
117
- description?: string;
118
- }
119
-
120
- export function PickerModal({
121
- visible,
122
- title,
123
- items,
124
- placeholder,
125
- emptyMessage,
126
- onSelect,
127
- onCancel,
128
- }: {
129
- visible: boolean;
130
- title: string;
131
- items: PickerItem[];
132
- placeholder: string;
133
- emptyMessage?: string;
134
- onSelect: (value: string) => void;
135
- onCancel?: () => void;
136
- }) {
137
- return (
138
- <Modal visible={visible} title={title}>
139
- {items.length === 0 && emptyMessage ? (
140
- <Text dimColor={true} italic={true}>
141
- {emptyMessage}
142
- </Text>
143
- ) : (
144
- <Dropdown
145
- items={items}
146
- placeholder={placeholder}
147
- isActive={visible}
148
- onSelect={(item) => onSelect(item.value)}
149
- onCancel={onCancel}
150
- />
151
- )}
152
- </Modal>
153
- );
154
- }
155
-
156
- export function MessageView({
157
- viewRef,
158
- contentRef,
159
- messages,
160
- streaming,
161
- stream,
162
- error,
163
- scrollOffset,
164
- viewHeight,
165
- contentHeight,
166
- }: {
167
- viewRef: React.RefObject<DOMElement | null>;
168
- contentRef: React.RefObject<DOMElement | null>;
169
- messages: ChatMessage[];
170
- streaming: boolean;
171
- stream: StreamState;
172
- error: string | null;
173
- scrollOffset: number;
174
- viewHeight: number;
175
- contentHeight: number;
176
- }) {
177
- return (
178
- <Box flexGrow={1} overflow="hidden">
179
- <Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
180
- <Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
181
- {messages.map((msg, i) => (
182
- // biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
183
- <MessageItem key={i} msg={msg} messages={messages} index={i} />
184
- ))}
185
- {streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
186
- {error && <Text color="red">Error: {error}</Text>}
187
- </Box>
188
- </Box>
189
- <Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
190
- </Box>
191
- );
192
- }
@@ -1,153 +0,0 @@
1
- import { Box, Text } from 'ink';
2
- import type { SlashCommand } from '../commands';
3
- import { useChatContext } from '../context/chat';
4
- import { type InputActions, useInputHandler } from '../hooks/useInputHandler';
5
-
6
- interface InputBoxProps {
7
- onSubmit: (text: string) => void;
8
- onScrollUp?: () => void;
9
- onScrollDown?: () => void;
10
- isActive?: boolean;
11
- model?: string;
12
- history?: string[];
13
- }
14
-
15
- function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
16
- if (!commands.length) {
17
- return null;
18
- }
19
- return (
20
- <Box flexDirection="column" marginBottom={1}>
21
- {commands.map((cmd, i) => (
22
- <Box key={cmd.name} paddingX={1}>
23
- <Text color={i === selectedIndex ? 'green' : undefined} bold={i === selectedIndex}>
24
- {i === selectedIndex ? '▸ ' : ' '}
25
- {cmd.name}
26
- </Text>
27
- <Text dimColor={true}> {cmd.description}</Text>
28
- </Box>
29
- ))}
30
- </Box>
31
- );
32
- }
33
-
34
- function InputFooter({
35
- model,
36
- attachmentName,
37
- attachmentError,
38
- hasContent,
39
- isCommandMode,
40
- }: {
41
- model: string;
42
- attachmentName: string | null;
43
- attachmentError: string | null;
44
- hasContent: boolean;
45
- isCommandMode: boolean;
46
- }) {
47
- const hint = hasContent
48
- ? isCommandMode
49
- ? '↑↓ select · Enter execute'
50
- : 'Enter to send · Shift+Enter for newline'
51
- : 'Type / for commands';
52
-
53
- return (
54
- <Box justifyContent="space-between">
55
- <Box gap={1}>
56
- {model && (
57
- <Text color="white" bold={true}>
58
- {model}
59
- </Text>
60
- )}
61
- {attachmentName && <Text color="cyan">📷 {attachmentName}</Text>}
62
- {attachmentError && <Text color="red">{attachmentError}</Text>}
63
- </Box>
64
- <Text dimColor={true}>{hint}</Text>
65
- </Box>
66
- );
67
- }
68
-
69
- function InputDisplay({
70
- value,
71
- isCommandMode,
72
- streaming,
73
- isActive,
74
- }: {
75
- value: string;
76
- isCommandMode: boolean;
77
- streaming: boolean;
78
- isActive: boolean;
79
- }) {
80
- const showCursor = !streaming && isActive;
81
- if (!value.length) {
82
- return <Text>{showCursor && <Text inverse={true}>▎</Text>}</Text>;
83
- }
84
- const lines = value.split('\n');
85
- return (
86
- <>
87
- {lines.map((line, i) => (
88
- // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
89
- <Text key={`${i}-${line}`} wrap="wrap">
90
- {i === 0 && isCommandMode ? <Text color="green">{line}</Text> : line}
91
- {i === lines.length - 1 && showCursor && <Text inverse={true}>▎</Text>}
92
- </Text>
93
- ))}
94
- </>
95
- );
96
- }
97
-
98
- export function InputBox({
99
- onSubmit,
100
- onScrollUp,
101
- onScrollDown,
102
- isActive = true,
103
- model = '',
104
- history = [],
105
- }: InputBoxProps) {
106
- const { session, toggles, attachment, models, abort, registry } = useChatContext();
107
-
108
- const actions: InputActions = {
109
- onCtrlC: abort.onCtrlC,
110
- onEsc: abort.onEsc,
111
- onPaste: attachment.onPaste,
112
- onNew: session.onNew,
113
- onCycleModel: models.cycleModel,
114
- onTogglePicker: toggles.onTogglePicker,
115
- onToggleSessionPicker: toggles.onToggleSessionPicker,
116
- onScrollUp,
117
- onScrollDown,
118
- modelCount: models.models.length,
119
- };
120
-
121
- const { value, commands, cmdIndex, isCommandMode } = useInputHandler({
122
- isActive,
123
- streaming: session.streaming,
124
- history,
125
- actions,
126
- onSubmit,
127
- pluginCommands: registry.getCommands(),
128
- });
129
-
130
- return (
131
- <Box
132
- flexDirection="column"
133
- flexShrink={0}
134
- backgroundColor="#222222"
135
- paddingX={1}
136
- paddingY={1}
137
- marginX={1}
138
- marginTop={1}
139
- >
140
- {isCommandMode && <CommandHints commands={commands} selectedIndex={cmdIndex} />}
141
- <Box flexDirection="column" minHeight={2}>
142
- <InputDisplay value={value} isCommandMode={isCommandMode} streaming={session.streaming} isActive={isActive} />
143
- </Box>
144
- <InputFooter
145
- model={model}
146
- attachmentName={attachment.attachment?.name ?? null}
147
- attachmentError={attachment.attachmentError}
148
- hasContent={value.length > 0}
149
- isCommandMode={isCommandMode}
150
- />
151
- </Box>
152
- );
153
- }
@@ -1,268 +0,0 @@
1
- import { type Key, useInput, useStdin } from 'ink';
2
- import type { SlashCommand as PluginSlashCommand } from 'mu-agents';
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { matchCommands, type SlashCommand } from '../commands';
5
-
6
- const BACKSPACE_BYTES = new Set(['\x7f', '\x08']);
7
-
8
- export interface InputActions {
9
- onCtrlC?: () => void;
10
- onPaste?: () => void;
11
- onNew?: () => void;
12
- onCycleModel?: () => void;
13
- onTogglePicker?: () => void;
14
- onToggleSessionPicker?: () => void;
15
- onEsc?: () => void;
16
- onScrollUp?: () => void;
17
- onScrollDown?: () => void;
18
- modelCount?: number;
19
- }
20
-
21
- interface InputState {
22
- value: string;
23
- commands: SlashCommand[];
24
- cmdIndex: number;
25
- isCommandMode: boolean;
26
- }
27
-
28
- interface UseInputHandlerOptions {
29
- isActive: boolean;
30
- streaming: boolean;
31
- history: string[];
32
- actions: InputActions;
33
- onSubmit: (text: string) => void;
34
- pluginCommands?: PluginSlashCommand[];
35
- }
36
-
37
- // Build a stable key identifier from an Ink key event
38
- function keyId(input: string, key: Key): string | null {
39
- if (key.ctrl && key.shift && input) {
40
- return `ctrl+shift+${input}`;
41
- }
42
- if (key.ctrl && input) {
43
- return `ctrl+${input}`;
44
- }
45
- if (key.escape) {
46
- return 'escape';
47
- }
48
- if (key.pageUp) {
49
- return 'pageup';
50
- }
51
- if (key.pageDown) {
52
- return 'pagedown';
53
- }
54
- if (key.return) {
55
- return key.shift ? 'shift+return' : 'return';
56
- }
57
- if (key.tab) {
58
- return 'tab';
59
- }
60
- if (key.upArrow) {
61
- return 'up';
62
- }
63
- if (key.downArrow) {
64
- return 'down';
65
- }
66
- if (key.backspace || key.delete) {
67
- return 'backspace';
68
- }
69
- return null;
70
- }
71
-
72
- function useHistoryNavigation(value: string, history: string[]) {
73
- const idx = useRef(-1);
74
- const draft = useRef('');
75
-
76
- const up = (): string | null => {
77
- if (!history.length) {
78
- return null;
79
- }
80
- if (idx.current === -1) {
81
- draft.current = value;
82
- idx.current = history.length - 1;
83
- } else if (idx.current > 0) {
84
- idx.current -= 1;
85
- }
86
- return history[idx.current] ?? null;
87
- };
88
-
89
- const down = (): string | null => {
90
- if (idx.current === -1) {
91
- return null;
92
- }
93
- if (idx.current < history.length - 1) {
94
- idx.current += 1;
95
- return history[idx.current] ?? null;
96
- }
97
- idx.current = -1;
98
- return draft.current;
99
- };
100
-
101
- return { up, down, reset: () => (idx.current = -1) };
102
- }
103
-
104
- function useRawBackspace(isActive: boolean, setValue: (fn: (p: string) => string) => void) {
105
- const { stdin } = useStdin();
106
- const handledRef = useRef(false);
107
-
108
- useEffect(() => {
109
- if (!(stdin && isActive)) {
110
- return;
111
- }
112
- const onData = (data: Buffer) => {
113
- if (BACKSPACE_BYTES.has(data.toString())) {
114
- handledRef.current = true;
115
- setValue((p) => p.slice(0, -1));
116
- }
117
- };
118
- stdin.on('data', onData);
119
- return () => {
120
- stdin.off('data', onData);
121
- };
122
- }, [stdin, isActive, setValue]);
123
-
124
- return handledRef;
125
- }
126
-
127
- const COMMAND_ACTIONS: Record<string, keyof InputActions> = {
128
- model: 'onTogglePicker',
129
- sessions: 'onToggleSessionPicker',
130
- new: 'onNew',
131
- };
132
-
133
- interface BindingCtx {
134
- value: string;
135
- setValue: React.Dispatch<React.SetStateAction<string>>;
136
- setCmdIndex: React.Dispatch<React.SetStateAction<number>>;
137
- nav: ReturnType<typeof useHistoryNavigation>;
138
- submit: () => void;
139
- isCommandMode: boolean;
140
- commands: SlashCommand[];
141
- actions: InputActions;
142
- }
143
-
144
- type Binding = (ctx: BindingCtx) => void;
145
-
146
- const BINDINGS: Record<string, Binding> = {
147
- 'ctrl+c': (c) => c.actions.onCtrlC?.(),
148
- 'ctrl+v': (c) => c.actions.onPaste?.(),
149
- 'ctrl+o': (c) => c.actions.onTogglePicker?.(),
150
- 'ctrl+s': (c) => c.submit(),
151
- 'ctrl+j': (c) => c.setValue((p) => `${p}\n`),
152
- 'ctrl+m': (c) => {
153
- if (c.actions.modelCount) {
154
- c.actions.onCycleModel?.();
155
- }
156
- },
157
- 'ctrl+n': (c) => {
158
- c.actions.onNew?.();
159
- c.setValue('');
160
- c.nav.reset();
161
- },
162
- escape: (c) => c.actions.onEsc?.(),
163
- pageup: (c) => c.actions.onScrollUp?.(),
164
- pagedown: (c) => c.actions.onScrollDown?.(),
165
- 'shift+return': (c) => c.setValue((p) => `${p}\n`),
166
- return: (c) => c.submit(),
167
- tab: (c) => c.setValue((p) => `${p} `),
168
- up: (c) => {
169
- if (c.isCommandMode) {
170
- c.setCmdIndex((i) => (i > 0 ? i - 1 : c.commands.length - 1));
171
- return;
172
- }
173
- const r = c.nav.up();
174
- if (r !== null) {
175
- c.setValue(r);
176
- }
177
- },
178
- down: (c) => {
179
- if (c.isCommandMode) {
180
- c.setCmdIndex((i) => (i < c.commands.length - 1 ? i + 1 : 0));
181
- return;
182
- }
183
- const r = c.nav.down();
184
- if (r !== null) {
185
- c.setValue(r);
186
- }
187
- },
188
- };
189
-
190
- function handleBackspace(c: BindingCtx, alreadyHandled: boolean) {
191
- if (!alreadyHandled) {
192
- c.setValue((p) => p.slice(0, -1));
193
- }
194
- c.nav.reset();
195
- }
196
-
197
- function handleInsert(input: string, c: BindingCtx) {
198
- if (input && input.length === 1) {
199
- c.setValue((p) => p + input);
200
- c.nav.reset();
201
- }
202
- }
203
-
204
- function executeCommand(cmd: SlashCommand, args: string, actions: InputActions): void {
205
- if (cmd.execute) {
206
- cmd.execute(args);
207
- } else if (cmd.action) {
208
- const actionKey = COMMAND_ACTIONS[cmd.action];
209
- if (actionKey) {
210
- (actions[actionKey] as (() => void) | undefined)?.();
211
- }
212
- }
213
- }
214
-
215
- export function useInputHandler(options: UseInputHandlerOptions): InputState {
216
- const { isActive, streaming, history, actions, onSubmit, pluginCommands } = options;
217
- const [value, setValue] = useState('');
218
- const [cmdIndex, setCmdIndex] = useState(0);
219
- const nav = useHistoryNavigation(value, history);
220
- const backspaceHandledRef = useRawBackspace(isActive, setValue);
221
-
222
- const commands = useMemo(() => matchCommands(value.trim(), pluginCommands), [value, pluginCommands]);
223
- const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
224
-
225
- const submit = useCallback(() => {
226
- if (streaming) {
227
- return;
228
- }
229
- if (isCommandMode) {
230
- const cmd = commands[cmdIndex];
231
- if (cmd) {
232
- const args = value.trim().slice(cmd.name.length).trim();
233
- setValue('');
234
- executeCommand(cmd, args, actions);
235
- }
236
- return;
237
- }
238
- if (!value.trim()) {
239
- return;
240
- }
241
- onSubmit(value);
242
- setValue('');
243
- nav.reset();
244
- }, [streaming, isCommandMode, commands, cmdIndex, value, actions, onSubmit, nav]);
245
-
246
- useInput(
247
- (input, key) => {
248
- const alreadyHandled = backspaceHandledRef.current;
249
- backspaceHandledRef.current = false;
250
-
251
- const ctx: BindingCtx = { value, setValue, setCmdIndex, nav, submit, isCommandMode, commands, actions };
252
- const id = keyId(input, key);
253
-
254
- if (id === 'backspace') {
255
- handleBackspace(ctx, alreadyHandled);
256
- return;
257
- }
258
- if (id && BINDINGS[id]) {
259
- BINDINGS[id](ctx);
260
- return;
261
- }
262
- handleInsert(input, ctx);
263
- },
264
- { isActive },
265
- );
266
-
267
- return { value, commands, cmdIndex, isCommandMode };
268
- }
@@ -1,52 +0,0 @@
1
- import { useApp } from 'ink';
2
- import type { PluginRegistry } from 'mu-agents';
3
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
- import { useRef } from 'react';
5
- import { listSessions, type SessionInfo } from '../session';
6
- import { type AbortState, useAbort } from './useAbort';
7
- import { type ChatSessionState, useChatSession } from './useChatSession';
8
- import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useChatUI';
9
- import { type ModelListState, useModelList } from './useModelList';
10
-
11
- const ABORT_TIMEOUT_MS = 2000;
12
-
13
- export interface ChatContextValue {
14
- session: ChatSessionState;
15
- toggles: TogglesState;
16
- attachment: AttachmentState;
17
- models: ModelListState;
18
- abort: AbortState;
19
- sessions: SessionInfo[];
20
- registry: PluginRegistry;
21
- }
22
-
23
- export function useChat(
24
- config: ProviderConfig,
25
- registry: PluginRegistry,
26
- initialMessages?: ChatMessage[],
27
- ): ChatContextValue {
28
- const { exit } = useApp();
29
- const controllerRef = useRef<AbortController | null>(null);
30
- const attachment = useAttachment();
31
- const toggles = useToggles();
32
- const models = useModelList(config.baseUrl, config.model);
33
- const session = useChatSession({
34
- config,
35
- currentModel: models.currentModel,
36
- attachment,
37
- controllerRef,
38
- initialMessages,
39
- registry,
40
- });
41
- const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS);
42
-
43
- return {
44
- session,
45
- toggles,
46
- attachment,
47
- models,
48
- abort,
49
- sessions: toggles.showSessionPicker ? listSessions() : [],
50
- registry,
51
- };
52
- }