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.
- package/README.md +49 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /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-
|
|
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 &&
|
|
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-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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=
|
|
8
|
+
<Text color={theme.reasoning.title} italic={true}>
|
|
7
9
|
thinking
|
|
8
10
|
</Text>
|
|
9
|
-
<Text
|
|
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}
|
|
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-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
42
|
-
const argSummary =
|
|
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,
|
|
59
|
+
renderToolOutput(name, args, result.content, result.error ?? false, hint)
|
|
55
60
|
)}
|
|
56
61
|
</Box>
|
|
57
62
|
);
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
function renderToolOutput(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 ?
|
|
108
|
+
<Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
|
|
86
109
|
{error ? '✗' : '✓'} {name}
|
|
87
|
-
{
|
|
110
|
+
{summary && (
|
|
88
111
|
<>
|
|
89
112
|
{' '}
|
|
90
|
-
<Text dimColor={true}>{
|
|
113
|
+
<Text dimColor={true}>{summary}</Text>
|
|
91
114
|
</>
|
|
92
115
|
)}
|
|
93
116
|
</Text>
|
|
94
|
-
<Box flexDirection="column" backgroundColor=
|
|
95
|
-
<Text color=
|
|
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-
|
|
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=
|
|
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=
|
|
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=
|
|
23
|
-
<Text color=
|
|
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">
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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 ?
|
|
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
|
|
111
|
+
<Text color={theme.dropdown.placeholder}>{placeholder} </Text>
|
|
85
112
|
<Text>{query}</Text>
|
|
86
|
-
<Text inverse={true}
|
|
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=
|
|
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
|
|
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=
|
|
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 ??
|
|
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=
|
|
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
|
+
}
|